From 10723c4b48dc6f063ecb8076d6f314e52914225e Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 5 Jul 2026 15:49:26 -0400
Subject: [PATCH 01/60] sp_HumanEvents: allow fractional-millisecond
@query_duration_ms floor
Change @query_duration_ms from integer to decimal(18,3) so a sub-1ms
query-capture floor can be expressed (e.g. 0.5 = 500 microseconds)
instead of only whole milliseconds or 0 (no floor). The ms-to-microsecond
conversion is wrapped in CONVERT(bigint, ...) so the event-session
duration predicate always receives a clean integer literal.
Whole-millisecond inputs behave exactly as before; 0 still removes the
floor entirely.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
sp_HumanEvents/sp_HumanEvents.sql | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/sp_HumanEvents/sp_HumanEvents.sql b/sp_HumanEvents/sp_HumanEvents.sql
index aacb60cb..72eabfca 100644
--- a/sp_HumanEvents/sp_HumanEvents.sql
+++ b/sp_HumanEvents/sp_HumanEvents.sql
@@ -50,7 +50,7 @@ ALTER PROCEDURE
dbo.sp_HumanEvents
(
@event_type sysname = N'query',
- @query_duration_ms integer = 500,
+ @query_duration_ms decimal(18,3) = 500,
@query_sort_order nvarchar(20) = N'cpu',
@skip_plans bit = 0,
@blocking_duration_ms integer = 500,
@@ -138,7 +138,7 @@ BEGIN
description =
CASE ap.name
WHEN N'@event_type' THEN N'used to pick which session you want to run'
- WHEN N'@query_duration_ms' THEN N'(>=) used to set a minimum query duration to collect data for'
+ WHEN N'@query_duration_ms' THEN N'(>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration'
WHEN N'@query_sort_order' THEN 'when you use the "query" event, lets you choose which metrics to sort results by'
WHEN N'@skip_plans' THEN 'when you use the "query" event, lets you skip collecting actual execution plans'
WHEN N'@blocking_duration_ms' THEN N'(>=) used to set a minimum blocking duration to collect data for'
@@ -172,7 +172,7 @@ BEGIN
valid_inputs =
CASE ap.name
WHEN N'@event_type' THEN N'"blocking", "query", "waits", "recompiles", "compiles" and certain variations on those words'
- WHEN N'@query_duration_ms' THEN N'an integer'
+ WHEN N'@query_duration_ms' THEN N'a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations'
WHEN N'@query_sort_order' THEN '"cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time"'
WHEN N'@skip_plans' THEN '1 or 0'
WHEN N'@blocking_duration_ms' THEN N'an integer'
@@ -1294,7 +1294,7 @@ IF @query_duration_ms > 0
BEGIN
IF LOWER(@event_type) NOT LIKE N'%comp%' /* compile and recompile durations are tiny */
BEGIN
- SET @query_duration_filter += N' AND duration >= ' + CONVERT(nvarchar(20), (@query_duration_ms * 1000)) + @nc10;
+ SET @query_duration_filter += N' AND duration >= ' + CONVERT(nvarchar(20), CONVERT(bigint, @query_duration_ms * 1000)) + @nc10;
END;
END;
From 634268b7aff78165b0f0e5ff9aef47fd02f0a7fb Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 5 Jul 2026 15:49:27 -0400
Subject: [PATCH 02/60] docs: correct and complete parameter tables across all
READMEs
Audited every procedure's parameter table -- both the per-proc folder
READMEs and the aggregated root README -- against each procedure's
declared parameters and @help output, and corrected them.
Fixes:
- Wrong data types: sp_QuickieCache @top (bigint), sp_HumanEvents
@custom_name (sysname), sp_HumanEvents @query_duration_ms (decimal).
- Swapped valid inputs: sp_PressureDetector @skip_perfmon and
@sample_seconds.
- Wrong default: sp_QueryReproBuilder @end_date (NULL).
- Stale/incomplete valid inputs: @query_sort_order (event_time),
@execution_type_desc (failed), @sort_order (average prefix),
@target_type (table mode), @cleanup_targets (comma-separated combos).
- ~25 undocumented parameters added across sp_QuickieStore,
sp_QueryReproBuilder, sp_QuickieCache, sp_HumanEventsBlockViewer
(@skip_execution_plans), sp_IndexCleanup, sp_PerfCheck (@help),
TestBackupPerformance (@encryption_list).
- Restored dropped detail (observer-overhead / UTC / type-constraint
warnings, log default parentheticals, other qualifiers) while
preserving intentional house-style wording.
- TestBackupPerformance: corrected the defaults combination count
(24 -> 72) and the compression+encryption result-set description.
sp_LogHunter was already accurate. The Install-All bundle is left
untouched (CI regenerates it).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
ProtectSession/README.md | 4 +-
README.md | 85 +++++++++++++++++++++------------
TestBackupPerformance/README.md | 11 +++--
sp_HealthParser/README.md | 6 +--
sp_HumanEvents/README.md | 19 ++++----
sp_IndexCleanup/README.md | 1 +
sp_PerfCheck/README.md | 1 +
sp_PressureDetector/README.md | 10 ++--
sp_QueryReproBuilder/README.md | 9 ++--
sp_QueryStoreCleanup/README.md | 2 +-
sp_QuickieCache/README.md | 9 +++-
sp_QuickieStore/README.md | 12 +++--
12 files changed, 104 insertions(+), 65 deletions(-)
diff --git a/ProtectSession/README.md b/ProtectSession/README.md
index 0b47ae2d..2a3a478d 100644
--- a/ProtectSession/README.md
+++ b/ProtectSession/README.md
@@ -12,10 +12,10 @@ It is **not** a general-purpose blocking killer; it watches exactly one session
| parameter_name | data_type | description | valid_inputs | defaults |
|---------------------------------|---------------|------------------------------------------------------------------------------------------|-----------------------------|----------------------|
-| @protected_session_id | integer | the session_id to keep alive | a session_id of another spid | NULL (required) |
+| @protected_session_id | integer | the session_id you want kept alive (watched, not run from) | a session_id of another spid | NULL (required) |
| @block_threshold_seconds | integer | how long the protected session must wait on an LCK before any action | a positive integer | 5 |
| @check_interval_seconds | integer | how often each cycle samples and acts | a positive integer | 1 |
-| @abort_on_modification_block | bit | when blocked by a permanent-data modification, kill the protected session instead | 0 or 1 | 0 |
+| @abort_on_modification_block | bit | when blocked by a permanent-data modification, kill the protected session instead of the blocker | 0 or 1 | 0 |
| @kill_modification_blockers | bit | kill permanent-data modification blockers too (rolls back their work) | 0 or 1 | 0 |
| @abort_reason | nvarchar(2048)| free-text reason printed when the protected session is aborted | any string or NULL | NULL |
| @debug | bit | print each cycle's `#blockers` contents | 0 or 1 | 0 |
diff --git a/README.md b/README.md
index 58d3a1b6..d7da2d29 100644
--- a/README.md
+++ b/README.md
@@ -81,11 +81,11 @@ Current valid parameter details:
| @minimum_disk_latency_ms | smallint | low bound for reporting disk latency | a reasonable number of milliseconds for disk latency | 100 |
| @cpu_utilization_threshold | smallint | low bound for reporting high cpu utlization | a reasonable cpu utlization percentage | 50 |
| @skip_waits | bit | skips waits when you do not need them on every run | 0 or 1 | 0 |
-| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | a valid tinyint: 0-255 | 0 |
-| @sample_seconds | tinyint | take a sample of your server's metrics | 0 or 1 | 0 |
-| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 |
-| @log_database_name | sysname | database to store logging tables | valid database name | NULL |
-| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL |
+| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | 0 or 1 | 0 |
+| @sample_seconds | tinyint | take a sample of your server's metrics | a valid tinyint: 0-255 | 0 |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) |
+| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) |
| @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'PressureDetector' |
| @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 |
| @troubleshoot_blocking | bit | show blocking chains instead of pressure analysis | 0 or 1 | 0 |
@@ -125,8 +125,8 @@ Current valid parameter details:
| parameter | name | description | valid_inputs | defaults |
|------------------------|----------|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|-------------------------------------------------|
| @event_type | sysname | used to pick which session you want to run | "blocking", "query", "waits", "recompiles", "compiles" and certain variations on those words | "query" |
-| @query_duration_ms | integer | (>=) used to set a minimum query duration to collect data for | an integer | 500 (ms) |
-| @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", "spills", and you can add "avg" to sort by averages, e.g. "avg cpu" | "cpu" |
+| @query_duration_ms | decimal | (>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration | a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations | 500 (ms) |
+| @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time" | "cpu" |
| @skip_plans | bit | when you use the "query" event, lets you skip collecting actual execution plans |1 or 0 | 0 |
| @blocking_duration_ms | integer | (>=) used to set a minimum blocking duration to collect data for | an integer | 500 (ms) |
| @wait_type | nvarchar | (inclusive) filter to only specific wait types | a single wait type, or a CSV list of wait types | "all", which uses a list of "interesting" waits |
@@ -141,10 +141,10 @@ Current valid parameter details:
| @object_schema | sysname | (inclusive) the schema of the object you want to filter to; only needed with blocking events | a stringy thing | dbo |
| @requested_memory_mb | integer | (>=) the memory grant a query must ask for to have data collected | an integer | 0 |
| @seconds_sample | tinyint | the duration in seconds to run the event session for | an integer | 10 |
-| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits | 1 or 0 | 0 |
+| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits. only use if you're okay with potentially adding a lot of observer overhead on your system, or for testing purposes. | 1 or 0 | 0 |
| @target_output | sysname | output target for extended events | "ring_buffer" or "event_file" (event_file not available for Azure SQL DB or Managed Instance) | "ring_buffer" |
| @keep_alive | bit | creates a permanent session, either to watch live or log to a table from | 1 or 0 | 0 |
-| @custom_name | nvarchar | if you want to custom name a permanent session | a stringy thing | intentionally left blank |
+| @custom_name | sysname | if you want to custom name a permanent session | a stringy thing | intentionally left blank |
| @output_database_name | sysname | the database you want to log data to | a valid database name | intentionally left blank |
| @output_schema_name | sysname | the schema you want to log data to | a valid schema | dbo |
| @delete_retention_days | integer | how many days of logged data you want to keep | a POSITIVE integer | 3 (days) |
@@ -218,8 +218,8 @@ Current valid parameter details:
| parameter_name | data_type | description | valid_inputs | defaults |
|-----------------------|-----------|-------------------------------------------------|------------------------------------------------------------------------|------------------------------------|
-| @session_name | sysname | name of the extended event session to pull from | extended event session name capturing sqlserver.blocked_process_report | keeper_HumanEvents_blocking |
-| @target_type | sysname | target of the extended event session | event_file or ring_buffer | NULL |
+| @session_name | sysname | name of the extended event session to pull from | extended event session name capturing sqlserver.blocked_process_report, system_health also works | keeper_HumanEvents_blocking |
+| @target_type | sysname | target type of the extended event session (ring buffer, file) or 'table' to read from a table | event_file or ring_buffer or table | NULL |
| @start_date | datetime2 | filter by date | a reasonable date | NULL; will shortcut to last 7 days |
| @end_date | datetime2 | filter by date | a reasonable date | NULL |
| @database_name | sysname | filter by database name | a database that exists on this server | NULL |
@@ -227,14 +227,15 @@ Current valid parameter details:
| @target_database | sysname | database containing the table with BPR data | a valid database name | NULL |
| @target_schema | sysname | schema of the table | a valid schema name | NULL |
| @target_table | sysname | table name | a valid table name | NULL |
-| @target_column | sysname | column containing XML data | a valid column name | NULL |
-| @timestamp_column | sysname | column containing timestamp (optional) | a valid column name | NULL |
-| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 |
-| @log_database_name | sysname | database to store logging tables | a valid database name | NULL |
-| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL |
+| @target_column | sysname | column containing XML data | an XML column containing blocked process report data | NULL |
+| @timestamp_column | sysname | column containing UTC timestamp for filtering (optional). MUST be stored in UTC — @start_date and @end_date are shifted to UTC internally to match the XML @timestamp attribute, and the same UTC-shifted values are used for this column filter. A column in local time will be filtered against the wrong window. | a datetime / datetime2 / datetimeoffset column storing UTC timestamps | NULL |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | a valid database name | NULL (current database) |
+| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL (dbo) |
| @log_table_name_prefix| sysname | prefix for all logging tables | a valid table name prefix | 'HumanEventsBlockViewer' |
| @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | a valid integer | 30 |
-| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 | 5000 |
+| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 (0 = unlimited) | 5000 |
+| @skip_execution_plans | bit | skip gathering and returning the execution plans result set and jump straight to the findings rollup | 0 or 1 | 0 |
| @help | bit | how you got here | 0 or 1 | 0 |
| @debug | bit | dumps raw temp table contents | 0 or 1 | 0 |
| @version | varchar | OUTPUT; for support | none; OUTPUT | none; OUTPUT |
@@ -277,14 +278,14 @@ Current valid parameter details:
| parameter_name | data_type | description | valid_inputs | defaults |
|-----------------------------------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database name if NULL |
-| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg prefix also accepted, e.g. "avg cpu") | cpu |
+| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg/average prefix also accepted, e.g. avg cpu, average duration) | cpu |
| @top | bigint | the number of queries you want to pull back | a positive integer between 1 and 9,223,372,036,854,775,807 | 10 |
| @start_date | datetimeoffset | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days |
| @end_date | datetimeoffset | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL |
| @timezone | sysname | user specified time zone to override dates displayed in results | SELECT tzi.* FROM sys.time_zone_info AS tzi; | NULL |
| @execution_count | bigint | the minimum number of executions a query must have | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL |
| @duration_ms | bigint | the minimum duration a query must have to show up in results | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL |
-| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception) | regular, aborted, exception | NULL |
+| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception, failed); failed bundles aborted and exception | regular, aborted, exception, failed | NULL |
| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL |
| @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database, can use wildcards | NULL |
| @include_plan_ids | nvarchar | a list of plan ids to search for | a string; comma separated for multiple ids | NULL |
@@ -310,7 +311,7 @@ Current valid parameter details:
| @query_type | varchar | filter for only ad hoc queries or only from queries from modules | ad hoc, adhoc, proc, procedure, whatever. | NULL |
| @expert_mode | bit | returns additional columns and results | 0 or 1 | 0 |
| @hide_help_table | bit | hides the "bottom table" that shows help and support information | 0 or 1 | 0 |
-| @format_output | bit | returns numbers formatted with commas | 0 or 1 | 1 |
+| @format_output | bit | returns numbers formatted with commas and most decimals rounded away | 0 or 1 | 1 |
| @get_all_databases | bit | looks for query store enabled user databases and returns combined results from all of them | 0 or 1 | 0 |
| @include_databases | nvarchar(MAX) | comma-separated list of databases to include (only when @get_all_databases = 1) | a string; comma separated database names | NULL |
| @exclude_databases | nvarchar(MAX) | comma-separated list of databases to exclude (only when @get_all_databases = 1) | a string; comma separated database names | NULL |
@@ -321,8 +322,15 @@ Current valid parameter details:
| @regression_baseline_end_date | datetimeoffset | the end date of the baseline that you are checking for regressions against (if any), will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL; One week after @regression_baseline_start_date if that is specified |
| @regression_comparator | varchar | what difference to use ('relative' or 'absolute') when comparing @sort_order's metric for the normal time period with any regression time period. | relative, absolute | NULL; absolute if @regression_baseline_start_date is specified |
| @regression_direction | varchar | when comparing against any regression baseline, what do you want the results sorted by ('magnitude', 'improved', or 'regressed')? | regressed, worse, improved, better, magnitude, absolute, whatever | NULL; regressed if @regression_baseline_start_date is specified |
-| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash | 0 or 1 | 0 |
+| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash, may be skewed by query_hash and query_plan_hash bugs with forced plans/plan guides | 0 or 1 | 0 |
+| @find_high_impact | bit | finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions | 0 or 1 | 0 |
+| @primary_window | nvarchar | with @find_high_impact, restricts results to queries whose majority activity is in this window (business, off-hours, or weekend) | business, off-hours, or weekend (any unambiguous prefix works: b, biz, off, overnight, w, wknd, etc.) | NULL |
| @include_maintenance | bit | add maintenance operations such as index creation to the result set | 0 or 1 | 0 |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | a valid database name | NULL; current database |
+| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL; dbo |
+| @log_table_name_prefix | sysname | prefix for all logging table names | a valid identifier | QuickieStore |
+| @log_retention_days | integer | days of data to retain, 0 = keep indefinitely | a positive integer, or 0 | 30 |
| @help | bit | how you got here | 0 or 1 | 0 |
| @debug | bit | prints dynamic sql, statement length, parameter and variable values, and raw temp table contents | 0 or 1 | 0 |
| @troubleshoot_performance | bit | set statistics xml on for queries against views | 0 or 1 | 0 |
@@ -349,15 +357,20 @@ Current valid parameter details:
| parameter_name | data_type | description | valid_inputs | defaults |
|-----------------------------|--------------|------------------------------------------------------------------------------------------------------|------------------------------------------|------------|
-| @top | integer | candidates per metric dimension before dedup | a positive integer | 10 |
+| @top | bigint | candidates per metric dimension before dedup | a positive integer | 10 |
+| @sort_order | varchar | secondary sort after impact_score | cpu, duration, reads, writes, memory, spills, executions | cpu |
| @database_name | sysname | filter to a specific database | a valid database name | NULL |
| @start_date | datetime | only include plans created after this date | a valid datetime | NULL |
| @end_date | datetime | only include plans created before this date | a valid datetime | NULL |
| @minimum_execution_count | bigint | minimum execution count to include a query | a positive integer | 2 |
| @ignore_system_databases | bit | exclude system databases (master, model, msdb, tempdb) | 0 or 1 | 1 |
| @impact_threshold | decimal(3,2) | minimum impact_score (0.00-1.00) to surface in results | 0.00 to 1.00 | 0.50 |
+| @find_single_use_plans | bit | show single-use plans consuming the most memory | 0 or 1 | 0 |
+| @find_duplicate_plans | bit | show query hashes with multiple cached plans | 0 or 1 | 0 |
| @debug | bit | print diagnostic information | 0 or 1 | 0 |
| @help | bit | display parameter help | 0 or 1 | 0 |
+| @version | varchar | OUTPUT; for support | none; OUTPUT | none; OUTPUT |
+| @version_date | datetime | OUTPUT; for support | none; OUTPUT | none; OUTPUT |
```sql
-- Basic execution
@@ -405,15 +418,22 @@ Current valid parameter details:
|---------------------|------------|-----------------------------------------------------------------------|----------------------------------------------------|----------------------------------|
| @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database if NULL |
| @start_date | datetimeoffset(7) | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days |
-| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | current date/time |
+| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL |
| @include_plan_ids | nvarchar(4000) | a list of plan ids to search for | a string; comma separated for multiple ids | NULL |
| @include_query_ids | nvarchar(4000) | a list of query ids to search for | a string; comma separated for multiple ids | NULL |
+| @include_query_hashes | nvarchar(4000) | a list of query hashes to search for | a string; comma separated for multiple hashes | NULL |
+| @include_plan_hashes | nvarchar(4000) | a list of query plan hashes to search for | a string; comma separated for multiple hashes | NULL |
+| @include_sql_handles | nvarchar(4000) | a list of sql handles to search for | a string; comma separated for multiple handles | NULL |
| @ignore_plan_ids | nvarchar(4000) | a list of plan ids to ignore | a string; comma separated for multiple ids | NULL |
| @ignore_query_ids | nvarchar(4000) | a list of query ids to ignore | a string; comma separated for multiple ids | NULL |
-| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL |
+| @ignore_query_hashes | nvarchar(4000) | a list of query hashes to ignore | a string; comma separated for multiple hashes | NULL |
+| @ignore_plan_hashes | nvarchar(4000) | a list of query plan hashes to ignore | a string; comma separated for multiple hashes | NULL |
+| @ignore_sql_handles | nvarchar(4000) | a list of sql handles to ignore | a string; comma separated for multiple handles | NULL |
+| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL |
| @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database | NULL |
-| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards added | NULL |
-| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards added | NULL |
+| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards will be added if missing | NULL |
+| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards will be added if missing | NULL |
+| @query_plan_xml | xml | a single query plan XML to process directly, bypassing Query Store | a valid ShowPlanXML document; when supplied, all Query Store filters are ignored | NULL |
| @help | bit | return available parameter details, etc. | 0 or 1 | 0 |
| @debug | bit | prints dynamic sql, statement length, parameter and variable values | 0 or 1 | 0 |
| @version | varchar(30) | OUTPUT; for support | none; OUTPUT | none; OUTPUT |
@@ -461,9 +481,9 @@ Current valid parameter details:
| @skip_waits | bit | skip the wait stats section | 0 or 1 | 0 |
| @pending_task_threshold | integer | minimum number of pending tasks to display | a valid integer | 10 |
| @use_ring_buffer | bit | use ring_buffer target instead of file target for faster collection | 0 or 1 | 0 |
-| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 |
-| @log_database_name | sysname | database to store logging tables | valid database name | NULL |
-| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) |
+| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) |
| @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'HealthParser' |
| @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 |
| @debug | bit | prints dynamic sql, selects from temp tables | 0 or 1 | 0 |
@@ -527,6 +547,7 @@ Current valid parameter details:
| Parameter | Data Type | Default | Description |
|-----------|-----------|---------|-------------|
| @database_name | sysname | NULL | Specific database to check; NULL runs against all accessible user databases |
+| @help | bit | 0 | Displays this help information |
| @debug | bit | 0 | Print diagnostic messages and intermediate query results |
| @version | varchar(30) | NULL OUTPUT | Returns version number |
| @version_date | datetime | NULL OUTPUT | Returns version date |
@@ -558,9 +579,11 @@ Current valid parameter details:
| @min_writes | bigint | 0 | Minimum number of writes for an index to be considered used |
| @min_size_gb | decimal(10,2) | 0 | Minimum size in GB for an index to be analyzed |
| @min_rows | bigint | 0 | Minimum number of rows for a table to be analyzed |
+| @dedupe_only | bit | 0 | When set to 1, only performs index deduplication but does not mark unused indexes for removal |
| @get_all_databases | bit | 0 | When set to 1, analyzes all eligible databases on the server |
| @include_databases | nvarchar(max) | NULL | Comma-separated list of databases to include (used with @get_all_databases = 1) |
| @exclude_databases | nvarchar(max) | NULL | Comma-separated list of databases to exclude (used with @get_all_databases = 1) |
+| @sort_order | varchar(20) | default | Controls final result ordering: default groups by script type within each database, object groups all rows for the same index together (Key Subset disables sort under their replacement index) |
| @help | bit | 0 | Displays help information |
| @debug | bit | 0 | Prints debug information during execution |
| @version | varchar(20) | NULL | OUTPUT parameter that returns the version number of the procedure |
@@ -585,8 +608,8 @@ Current valid parameter details:
| parameter_name | data_type | description | valid_inputs | defaults |
|---|---|---|---|---|
| @database_name | sysname | the database to clean query store in | a database name with query store enabled | NULL; current database if NULL |
-| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none | all |
-| @custom_query_filter | nvarchar(1024) | custom LIKE pattern for query text filtering | a valid LIKE pattern | NULL |
+| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none, or comma-separated combination | all |
+| @custom_query_filter | nvarchar(1024) | custom LIKE pattern for query text filtering; also applied when @cleanup_targets = all | a valid LIKE pattern | NULL |
| @dedupe_by | varchar(50) | deduplication strategy | all, query_hash, plan_hash, none | all |
| @min_age_days | integer | only remove queries not executed in this many days | a positive integer | NULL; no age filter |
| @report_only | bit | report what would be removed without removing | 0 or 1 | 0 |
diff --git a/TestBackupPerformance/README.md b/TestBackupPerformance/README.md
index 793fd82a..dd96fc6f 100644
--- a/TestBackupPerformance/README.md
+++ b/TestBackupPerformance/README.md
@@ -2,7 +2,7 @@
# TestBackupPerformance
-Finding the fastest backup settings for your database shouldn't require guesswork. This procedure tests every combination of file count (striping), compression, buffer count, and max transfer size, then ranks the results so you can see what actually works best on your hardware.
+Finding the fastest backup settings for your database shouldn't require guesswork. This procedure tests every combination of file count (striping), compression, encryption, buffer count, and max transfer size, then ranks the results so you can see what actually works best on your hardware.
Results are persisted to `dbo.backup_performance_results` so you can compare across runs, servers, and databases.
@@ -14,19 +14,20 @@ Results are persisted to `dbo.backup_performance_results` so you can compare acr
| @backup_path | nvarchar | directory path, DEFAULT for instance default, or NUL to discard | a valid directory path, DEFAULT, or NUL | NULL (required) |
| @file_count_list | varchar | comma-separated list of file counts (backup stripes) | comma-separated integers | 1,2,4 |
| @compression_list | varchar | comma-separated list: 0 = no compression, 1 = compressed | comma-separated 0s and 1s | 0,1 |
+| @encryption_list | varchar | comma-separated list: 0 = no encryption, 1 = encrypted (requires a server certificate in master) | comma-separated 0s and 1s | 0 |
| @buffer_count_list | varchar | comma-separated list of buffer counts (0 = SQL Server default) | comma-separated integers (0 for default) | 0,15,30,50 |
-| @max_transfer_size_list | varchar | comma-separated list of max transfer sizes in bytes (0 = default 1MB) | comma-separated integers, multiples of 65536, max 4194304 | 0,2097152,4194304 |
+| @max_transfer_size_list | varchar | comma-separated list of max transfer sizes in bytes (0 = default 1MB, max 4MB) | comma-separated integers, multiples of 65536, max 4194304 | 0,2097152,4194304 |
| @stats | tinyint | backup completion percent to print progress at | 1-100 | 1 |
| @iterations | integer | times to repeat each configuration for averaging | a positive integer | 1 |
| @help | bit | how you got here | 0 or 1 | 0 |
-| @debug | bit | prints dynamic sql, displays parameter and variable values | 0 or 1 | 0 |
+| @debug | bit | prints dynamic sql, displays parameter and variable values, and table contents | 0 or 1 | 0 |
| @version | varchar | OUTPUT; for support | none | none; OUTPUT |
| @version_date | datetime | OUTPUT; for support | none | none; OUTPUT |
## Examples
```sql
--- Test with defaults (24 combinations: 3 file counts x 2 compression x 4 buffer counts x 3 transfer sizes)
+-- Test with defaults (72 combinations: 3 file counts x 2 compression x 1 encryption x 4 buffer counts x 3 transfer sizes)
EXECUTE dbo.TestBackupPerformance
@database_name = N'YourDatabase',
@backup_path = N'D:\Backups';
@@ -61,7 +62,7 @@ EXECUTE dbo.TestBackupPerformance
## Result Sets
1. **All configurations ranked by throughput** -- every combination ranked best to worst
-2. **Best config per compression setting** -- fastest compressed vs fastest uncompressed
+2. **Best config per compression + encryption pairing** -- the fastest config in each compression/encryption category
3. **Parameter impact** -- which knob matters most (larger spread = bigger effect)
4. **Efficiency** -- best throughput per MB of buffer RAM (filtered to configs within 80% of peak)
5. **Consistency** -- min/max/stddev per config (only when `@iterations > 1`)
diff --git a/sp_HealthParser/README.md b/sp_HealthParser/README.md
index 80272d8d..6abfaec1 100644
--- a/sp_HealthParser/README.md
+++ b/sp_HealthParser/README.md
@@ -39,9 +39,9 @@ Typical result set will show you:
| @skip_waits | bit | skip the wait stats section | 0 or 1 | 0 |
| @pending_task_threshold | integer | minimum number of pending tasks to display | a valid integer | 10 |
| @use_ring_buffer | bit | use ring_buffer target instead of file target for faster collection | 0 or 1 | 0 |
-| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 |
-| @log_database_name | sysname | database to store logging tables | valid database name | NULL |
-| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) |
+| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) |
| @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'HealthParser' |
| @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 |
| @debug | bit | prints dynamic sql, selects from temp tables | 0 or 1 | 0 |
diff --git a/sp_HumanEvents/README.md b/sp_HumanEvents/README.md
index 0a7809cd..be7a9f10 100644
--- a/sp_HumanEvents/README.md
+++ b/sp_HumanEvents/README.md
@@ -47,7 +47,7 @@ Misuse of this procedure can harm performance. Be very careful about introducing
| parameter | data_type | description | valid_inputs | defaults |
|------------------------|----------------|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|-------------------------------------------------|
| @event_type | sysname | used to pick which session you want to run | "blocking", "query", "waits", "recompiles", "compiles" and certain variations on those words | "query" |
-| @query_duration_ms | integer | (>=) used to set a minimum query duration to collect data for | an integer | 500 (ms) |
+| @query_duration_ms | decimal | (>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration | a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations | 500 (ms) |
| @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time" | "cpu" |
| @skip_plans | bit | when you use the "query" event, lets you skip collecting actual execution plans |1 or 0 | 0 |
| @blocking_duration_ms | integer | (>=) used to set a minimum blocking duration to collect data for | an integer | 500 (ms) |
@@ -63,7 +63,7 @@ Misuse of this procedure can harm performance. Be very careful about introducing
| @object_schema | sysname | (inclusive) the schema of the object you want to filter to; only needed with blocking events | a stringy thing | dbo |
| @requested_memory_mb | integer | (>=) the memory grant a query must ask for to have data collected | an integer | 0 |
| @seconds_sample | tinyint | the duration in seconds to run the event session for | an integer | 10 |
-| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits | 1 or 0 | 0 |
+| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits. only use if you're okay with potentially adding a lot of observer overhead on your system, or for testing purposes. | 1 or 0 | 0 |
| @target_output | sysname | output target for extended events | "ring_buffer" or "event_file" (event_file not available for Azure SQL DB or Managed Instance) | "ring_buffer" |
| @keep_alive | bit | creates a permanent session, either to watch live or log to a table from | 1 or 0 | 0 |
| @custom_name | sysname | if you want to custom name a permanent session | a stringy thing | intentionally left blank |
@@ -178,7 +178,7 @@ ON SERVER
| parameter_name | data_type | description | valid_inputs | defaults |
|-----------------------|-----------|----------------------------------------------------|--------------------------------------------------------------------------------------------------|------------------------------------|
| @session_name | sysname | name of the extended event session to pull from | extended event session name capturing sqlserver.blocked_process_report, system_health also works | keeper_HumanEvents_blocking |
-| @target_type | sysname | target of the extended event session | event_file or ring_buffer or table | NULL |
+| @target_type | sysname | target type of the extended event session (ring buffer, file) or 'table' to read from a table | event_file or ring_buffer or table | NULL |
| @start_date | datetime2 | filter by date | a reasonable date | NULL; will shortcut to last 7 days |
| @end_date | datetime2 | filter by date | a reasonable date | NULL |
| @database_name | sysname | filter by database name | a database that exists on this server | NULL |
@@ -186,14 +186,15 @@ ON SERVER
| @target_database | sysname | database containing the table with BPR data | a valid database name | NULL |
| @target_schema | sysname | schema of the table | a valid schema name | NULL |
| @target_table | sysname | table name | a valid table name | NULL |
-| @target_column | sysname | column containing XML data | a valid column name | NULL |
-| @timestamp_column | sysname | column containing timestamp (optional) | a valid column name | NULL |
-| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 |
-| @log_database_name | sysname | database to store logging tables | a valid database name | NULL |
-| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL |
+| @target_column | sysname | column containing XML data | an XML column containing blocked process report data | NULL |
+| @timestamp_column | sysname | column containing UTC timestamp for filtering (optional). MUST be stored in UTC — @start_date and @end_date are shifted to UTC internally to match the XML @timestamp attribute, and the same UTC-shifted values are used for this column filter. A column in local time will be filtered against the wrong window. | a datetime / datetime2 / datetimeoffset column storing UTC timestamps | NULL |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | a valid database name | NULL (current database) |
+| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL (dbo) |
| @log_table_name_prefix| sysname | prefix for all logging tables | a valid table name prefix | 'HumanEventsBlockViewer' |
| @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | a valid integer | 30 |
-| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 | 5000 |
+| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 (0 = unlimited) | 5000 |
+| @skip_execution_plans | bit | skip gathering and returning the execution plans result set and jump straight to the findings rollup | 0 or 1 | 0 |
| @help | bit | how you got here | 0 or 1 | 0 |
| @debug | bit | dumps raw temp table contents | 0 or 1 | 0 |
| @version | varchar | OUTPUT; for support | none; OUTPUT | none; OUTPUT |
diff --git a/sp_IndexCleanup/README.md b/sp_IndexCleanup/README.md
index bc63ae0c..288d5204 100644
--- a/sp_IndexCleanup/README.md
+++ b/sp_IndexCleanup/README.md
@@ -33,6 +33,7 @@ The procedure requires SQL Server 2012 (11.0) or later due to the use of FORMAT
| @get_all_databases | bit | 0 | When set to 1, analyzes all eligible databases on the server |
| @include_databases | nvarchar(max) | NULL | Comma-separated list of databases to include (used with @get_all_databases = 1) |
| @exclude_databases | nvarchar(max) | NULL | Comma-separated list of databases to exclude (used with @get_all_databases = 1) |
+| @sort_order | varchar(20) | default | Controls final result ordering: default groups by script type within each database, object groups all rows for the same index together (Key Subset disables sort under their replacement index) |
| @help | bit | 0 | Displays help information |
| @debug | bit | 0 | Prints debug information during execution |
| @version | varchar(20) | NULL | OUTPUT parameter that returns the version number of the procedure |
diff --git a/sp_PerfCheck/README.md b/sp_PerfCheck/README.md
index dfa77cd8..6e65c056 100644
--- a/sp_PerfCheck/README.md
+++ b/sp_PerfCheck/README.md
@@ -23,6 +23,7 @@
| Parameter | Data Type | Default | Description |
|-----------|-----------|---------|-------------|
| @database_name | sysname | NULL | Specific database to check; NULL checks all accessible user databases |
+| @help | bit | 0 | Displays this help information |
| @debug | bit | 0 | Print diagnostic messages and intermediate query results |
| @version | varchar(30) | NULL OUTPUT | Returns version number |
| @version_date | datetime | NULL OUTPUT | Returns version date |
diff --git a/sp_PressureDetector/README.md b/sp_PressureDetector/README.md
index c06c9f45..6c33b34f 100644
--- a/sp_PressureDetector/README.md
+++ b/sp_PressureDetector/README.md
@@ -27,11 +27,11 @@ All you need to do is hit F5 to get information about:
| @minimum_disk_latency_ms | smallint | low bound for reporting disk latency | a reasonable number of milliseconds for disk latency | 100 |
| @cpu_utilization_threshold | smallint | low bound for reporting high cpu utlization | a reasonable cpu utlization percentage | 50 |
| @skip_waits | bit | skips waits when you do not need them on every run | 0 or 1 | 0 |
-| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | a valid tinyint: 0-255 | 0 |
-| @sample_seconds | tinyint | take a sample of your server's metrics | 0 or 1 | 0 |
-| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 |
-| @log_database_name | sysname | database to store logging tables | valid database name | NULL |
-| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL |
+| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | 0 or 1 | 0 |
+| @sample_seconds | tinyint | take a sample of your server's metrics | a valid tinyint: 0-255 | 0 |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) |
+| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) |
| @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'PressureDetector' |
| @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 |
| @troubleshoot_blocking | bit | show blocking chains instead of pressure analysis | 0 or 1 | 0 |
diff --git a/sp_QueryReproBuilder/README.md b/sp_QueryReproBuilder/README.md
index 91e659fd..198a3567 100644
--- a/sp_QueryReproBuilder/README.md
+++ b/sp_QueryReproBuilder/README.md
@@ -21,7 +21,7 @@ You can filter queries by plan_id or query_id, and optionally specify a date ran
|------------------------|--------------------|-----------------------------------------------------------------------|----------------------------------------------------|----------------------------------|
| @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database if NULL |
| @start_date | datetimeoffset(7) | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days |
-| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | current date/time |
+| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL |
| @include_plan_ids | nvarchar(4000) | a list of plan ids to search for | a string; comma separated for multiple ids | NULL |
| @include_query_ids | nvarchar(4000) | a list of query ids to search for | a string; comma separated for multiple ids | NULL |
| @include_query_hashes | nvarchar(4000) | a list of query hashes to search for | a string; comma separated for multiple hashes | NULL |
@@ -32,10 +32,11 @@ You can filter queries by plan_id or query_id, and optionally specify a date ran
| @ignore_query_hashes | nvarchar(4000) | a list of query hashes to ignore | a string; comma separated for multiple hashes | NULL |
| @ignore_plan_hashes | nvarchar(4000) | a list of query plan hashes to ignore | a string; comma separated for multiple hashes | NULL |
| @ignore_sql_handles | nvarchar(4000) | a list of sql handles to ignore | a string; comma separated for multiple handles | NULL |
-| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL |
+| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL |
| @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database | NULL |
-| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards added | NULL |
-| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards added | NULL |
+| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards will be added if missing | NULL |
+| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards will be added if missing | NULL |
+| @query_plan_xml | xml | a single query plan XML to process directly, bypassing Query Store | a valid ShowPlanXML document; when supplied, all Query Store filters are ignored | NULL |
| @help | bit | return available parameter details, etc. | 0 or 1 | 0 |
| @debug | bit | prints dynamic sql, statement length, parameter and variable values | 0 or 1 | 0 |
| @version | varchar(30) | OUTPUT; for support | none; OUTPUT | none; OUTPUT |
diff --git a/sp_QueryStoreCleanup/README.md b/sp_QueryStoreCleanup/README.md
index 795a6612..35188754 100644
--- a/sp_QueryStoreCleanup/README.md
+++ b/sp_QueryStoreCleanup/README.md
@@ -15,7 +15,7 @@ Queries with forced plans are always protected from removal.
| parameter_name | data_type | description | valid_inputs | defaults |
|---|---|---|---|---|
| @database_name | sysname | the database to clean query store in | a database name with query store enabled | NULL; current database if NULL |
-| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none | all |
+| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none, or comma-separated combination | all |
| @custom_query_filter | nvarchar(1024) | custom LIKE pattern for query text filtering; also applied when @cleanup_targets = all | a valid LIKE pattern | NULL |
| @dedupe_by | varchar(50) | deduplication strategy | all, query_hash, plan_hash, none | all |
| @min_age_days | integer | only remove queries whose last execution is older than this many days | a positive integer | NULL; no age filter |
diff --git a/sp_QuickieCache/README.md b/sp_QuickieCache/README.md
index 3ad47ef0..1834bb7c 100644
--- a/sp_QuickieCache/README.md
+++ b/sp_QuickieCache/README.md
@@ -34,15 +34,20 @@ While QuickieStore digs into Query Store data, QuickieCache uses the same Pareto
| parameter_name | data_type | description | default |
|---|---|---|---|
-| @top | integer | candidates per metric dimension before dedup | 10 |
+| @top | bigint | candidates per metric dimension before dedup | 10 |
+| @sort_order | varchar | secondary sort after impact_score | cpu |
| @database_name | sysname | filter to a specific database | NULL |
| @start_date | datetime | only include plans created after this date | NULL |
| @end_date | datetime | only include plans created before this date | NULL |
| @minimum_execution_count | bigint | minimum execution count to include a query | 2 |
-| @ignore_system_databases | bit | exclude system databases | 1 |
+| @ignore_system_databases | bit | exclude system databases (master, model, msdb, tempdb) | 1 |
| @impact_threshold | decimal(3,2) | minimum impact_score to surface | 0.50 |
+| @find_single_use_plans | bit | show single-use plans consuming the most memory | 0 |
+| @find_duplicate_plans | bit | show query hashes with multiple cached plans | 0 |
| @debug | bit | print diagnostic information | 0 |
| @help | bit | display parameter help | 0 |
+| @version | varchar | OUTPUT; for support | none; OUTPUT |
+| @version_date | datetime | OUTPUT; for support | none; OUTPUT |
## Examples
diff --git a/sp_QuickieStore/README.md b/sp_QuickieStore/README.md
index 521a891c..f131e868 100644
--- a/sp_QuickieStore/README.md
+++ b/sp_QuickieStore/README.md
@@ -28,14 +28,14 @@ Use the `@expert_mode` parameter to return additional details.
| parameter_name | data_type | description | valid_inputs | defaults |
|-----------------------------------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database name if NULL |
-| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg prefix also accepted, e.g. "avg cpu") | cpu |
+| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg/average prefix also accepted, e.g. avg cpu, average duration) | cpu |
| @top | bigint | the number of queries you want to pull back | a positive integer between 1 and 9,223,372,036,854,775,807 | 10 |
| @start_date | datetimeoffset | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days |
| @end_date | datetimeoffset | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL |
| @timezone | sysname | user specified time zone to override dates displayed in results | SELECT tzi.* FROM sys.time_zone_info AS tzi; | NULL |
| @execution_count | bigint | the minimum number of executions a query must have | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL |
| @duration_ms | bigint | the minimum duration a query must have to show up in results | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL |
-| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception) | regular, aborted, exception | NULL |
+| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception, failed); failed bundles aborted and exception | regular, aborted, exception, failed | NULL |
| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL |
| @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database, can use wildcards | NULL |
| @include_plan_ids | nvarchar | a list of plan ids to search for | a string; comma separated for multiple ids | NULL |
@@ -72,9 +72,15 @@ Use the `@expert_mode` parameter to return additional details.
| @regression_baseline_end_date | datetimeoffset | the end date of the baseline that you are checking for regressions against (if any), will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL; One week after @regression_baseline_start_date if that is specified |
| @regression_comparator | varchar | what difference to use ('relative' or 'absolute') when comparing @sort_order's metric for the normal time period with any regression time period. | relative, absolute | NULL; absolute if @regression_baseline_start_date is specified |
| @regression_direction | varchar | when comparing against any regression baseline, what do you want the results sorted by ('magnitude', 'improved', or 'regressed')? | regressed, worse, improved, better, magnitude, absolute, whatever | NULL; regressed if @regression_baseline_start_date is specified |
-| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash | 0 or 1 | 0 |
+| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash, may be skewed by query_hash and query_plan_hash bugs with forced plans/plan guides | 0 or 1 | 0 |
| @find_high_impact | bit | finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions | 0 or 1 | 0 |
+| @primary_window | nvarchar | with @find_high_impact, restricts results to queries whose majority activity is in this window (business, off-hours, or weekend) | business, off-hours, or weekend (any unambiguous prefix works: b, biz, off, overnight, w, wknd, etc.) | NULL |
| @include_maintenance | bit | Set this bit to 1 to add maintenance operations such as index creation to the result set | 0 or 1 | 0 |
+| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
+| @log_database_name | sysname | database to store logging tables | a valid database name | NULL; current database |
+| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL; dbo |
+| @log_table_name_prefix | sysname | prefix for all logging table names | a valid identifier | QuickieStore |
+| @log_retention_days | integer | days of data to retain, 0 = keep indefinitely | a positive integer, or 0 | 30 |
| @help | bit | how you got here | 0 or 1 | 0 |
| @debug | bit | prints dynamic sql, statement length, parameter and variable values, and raw temp table contents | 0 or 1 | 0 |
| @troubleshoot_performance | bit | set statistics xml on for queries against views | 0 or 1 | 0 |
From e39131b8cd9fb4497f3b7ef5f6b0a4ba2568f058 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 6 Jul 2026 15:20:25 -0400
Subject: [PATCH 03/60] sp_HumanEvents: add @keep_prepare_rpc to surface
prepared-statement RPC calls
Query mode keeps only events with a non-zero query_hash_signed. That filter is
load-bearing -- it strips hash-less noise -- but it also hides the sp_prepare /
sp_prepexec / sp_execute / sp_unprepare plumbing client drivers emit, since all of
it carries a zero query hash. That makes prepared-statement workloads impossible to
tell apart: pyodbc's fast_executemany on vs off look identical because only the inner
INSERT survives the filter, differing only by the parameter datatype.
@keep_prepare_rpc = 1 adds a result set (interactive/sampling path only) listing those
RPC calls chronologically with object_name, statement, sql_text, and per-call timing,
with no duration floor. It inherits whatever session filters are already in effect.
Default 0 leaves output byte-for-byte unchanged. The long-term logging-to-table path is
intentionally left alone to avoid bloating collection tables under load.
Verified end-to-end against pyodbc + ODBC Driver 18: fast_executemany off shows
sp_prepexec per row; on shows sp_describe_undeclared_parameters + one sp_prepare +
sp_execute per row. Compiles on SQL 2016/2019/2022. No version bump (not a release).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
README.md | 1 +
sp_HumanEvents/README.md | 9 +++
sp_HumanEvents/sp_HumanEvents.sql | 103 ++++++++++++++++++++++++++++++
3 files changed, 113 insertions(+)
diff --git a/README.md b/README.md
index d7da2d29..97be3885 100644
--- a/README.md
+++ b/README.md
@@ -128,6 +128,7 @@ Current valid parameter details:
| @query_duration_ms | decimal | (>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration | a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations | 500 (ms) |
| @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time" | "cpu" |
| @skip_plans | bit | when you use the "query" event, lets you skip collecting actual execution plans |1 or 0 | 0 |
+| @keep_prepare_rpc | bit | when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare) that are normally filtered out; shows whether a driver is preparing and reusing statements, e.g. pyodbc's fast_executemany | 1 or 0 | 0 |
| @blocking_duration_ms | integer | (>=) used to set a minimum blocking duration to collect data for | an integer | 500 (ms) |
| @wait_type | nvarchar | (inclusive) filter to only specific wait types | a single wait type, or a CSV list of wait types | "all", which uses a list of "interesting" waits |
| @wait_duration_ms | integer | (>=) used to set a minimum time per wait to collect data for | an integer | 10 (ms) |
diff --git a/sp_HumanEvents/README.md b/sp_HumanEvents/README.md
index be7a9f10..b0f07de0 100644
--- a/sp_HumanEvents/README.md
+++ b/sp_HumanEvents/README.md
@@ -50,6 +50,7 @@ Misuse of this procedure can harm performance. Be very careful about introducing
| @query_duration_ms | decimal | (>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration | a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations | 500 (ms) |
| @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time" | "cpu" |
| @skip_plans | bit | when you use the "query" event, lets you skip collecting actual execution plans |1 or 0 | 0 |
+| @keep_prepare_rpc | bit | when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare) that are normally filtered out; shows whether a driver is preparing and reusing statements, e.g. pyodbc's fast_executemany | 1 or 0 | 0 |
| @blocking_duration_ms | integer | (>=) used to set a minimum blocking duration to collect data for | an integer | 500 (ms) |
| @wait_type | nvarchar | (inclusive) filter to only specific wait types | a single wait type, or a CSV list of wait types | "all", which uses a list of "interesting" waits |
| @wait_duration_ms | integer | (>=) used to set a minimum time per wait to collect data for | an integer | 10 (ms) |
@@ -97,6 +98,14 @@ EXECUTE dbo.sp_HumanEvents
@event_type = 'waits',
@database_name = 'YourDatabase';
+-- Diagnose whether a client driver is preparing and reusing statements
+-- (e.g. pyodbc's fast_executemany). Adds a result set of the raw
+-- sp_prepare/sp_prepexec/sp_execute RPC calls that are normally hidden.
+EXECUTE dbo.sp_HumanEvents
+ @event_type = 'query',
+ @keep_prepare_rpc = 1,
+ @query_duration_ms = 0;
+
-- Set up a permanent session for logging
EXECUTE dbo.sp_HumanEvents
@event_type = 'query',
diff --git a/sp_HumanEvents/sp_HumanEvents.sql b/sp_HumanEvents/sp_HumanEvents.sql
index 72eabfca..44f7d8dc 100644
--- a/sp_HumanEvents/sp_HumanEvents.sql
+++ b/sp_HumanEvents/sp_HumanEvents.sql
@@ -53,6 +53,7 @@ ALTER PROCEDURE
@query_duration_ms decimal(18,3) = 500,
@query_sort_order nvarchar(20) = N'cpu',
@skip_plans bit = 0,
+ @keep_prepare_rpc bit = 0, /*adds a result set of prepared-statement RPC calls (sp_prepare/sp_prepexec/sp_execute) the query_hash filter hides; helps diagnose driver batching like pyodbc fast_executemany*/
@blocking_duration_ms integer = 500,
@wait_type nvarchar(4000) = N'ALL',
@wait_duration_ms integer = 10,
@@ -141,6 +142,7 @@ BEGIN
WHEN N'@query_duration_ms' THEN N'(>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration'
WHEN N'@query_sort_order' THEN 'when you use the "query" event, lets you choose which metrics to sort results by'
WHEN N'@skip_plans' THEN 'when you use the "query" event, lets you skip collecting actual execution plans'
+ WHEN N'@keep_prepare_rpc' THEN N'when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare) that are normally filtered out; the only reliable way to tell whether a client driver is preparing and reusing statements, e.g. pyodbc''s fast_executemany'
WHEN N'@blocking_duration_ms' THEN N'(>=) used to set a minimum blocking duration to collect data for'
WHEN N'@wait_type' THEN N'(inclusive) filter to only specific wait types'
WHEN N'@wait_duration_ms' THEN N'(>=) used to set a minimum time per wait to collect data for'
@@ -175,6 +177,7 @@ BEGIN
WHEN N'@query_duration_ms' THEN N'a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations'
WHEN N'@query_sort_order' THEN '"cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time"'
WHEN N'@skip_plans' THEN '1 or 0'
+ WHEN N'@keep_prepare_rpc' THEN N'1 or 0'
WHEN N'@blocking_duration_ms' THEN N'an integer'
WHEN N'@wait_type' THEN N'a single wait type, or a CSV list of wait types'
WHEN N'@wait_duration_ms' THEN N'an integer'
@@ -209,6 +212,7 @@ BEGIN
WHEN N'@query_duration_ms' THEN N'500 (ms)'
WHEN N'@query_sort_order' THEN N'"cpu"'
WHEN N'@skip_plans' THEN '0'
+ WHEN N'@keep_prepare_rpc' THEN N'0'
WHEN N'@blocking_duration_ms' THEN N'500 (ms)'
WHEN N'@wait_type' THEN N'"all", which uses a list of "interesting" waits'
WHEN N'@wait_duration_ms' THEN N'10 (ms)'
@@ -2250,6 +2254,105 @@ BEGIN
ELSE q.total_cpu_ms
END DESC
OPTION(RECOMPILE);
+
+ /*
+ Optional: surface the prepared-statement RPC plumbing that the query_hash_signed
+ filter above intentionally hides. sp_prepare, sp_prepexec, sp_execute, and sp_unprepare
+ all arrive with a zero query hash, so they never appear in the query results above.
+ Seeing them is the only reliable way to tell whether a client driver is preparing and
+ reusing statements. For example, pyodbc's fast_executemany switches an insert workload
+ from sp_prepexec-per-row (prepare and execute every time) to a single sp_prepare followed
+ by sp_execute-per-row (handle reuse); without this result set both look identical.
+ */
+ IF @keep_prepare_rpc = 1
+ BEGIN
+ WITH
+ prepare_rpc AS
+ (
+ SELECT
+ event_time =
+ DATEADD
+ (
+ MINUTE,
+ DATEDIFF
+ (
+ MINUTE,
+ GETUTCDATE(),
+ SYSDATETIME()
+ ),
+ oa.c.value('@timestamp', 'datetime2')
+ ),
+ event_type = oa.c.value('@name', 'sysname'),
+ database_name = oa.c.value('(action[@name="database_name"]/value/text())[1]', 'sysname'),
+ object_name = oa.c.value('(data[@name="object_name"]/value/text())[1]', 'sysname'),
+ statement = oa.c.value('(data[@name="statement"]/value/text())[1]', 'nvarchar(max)'),
+ sql_text = oa.c.value('(action[@name="sql_text"]/value/text())[1]', 'nvarchar(max)'),
+ duration_ms = oa.c.value('(data[@name="duration"]/value/text())[1]', 'bigint') / 1000.,
+ cpu_ms = oa.c.value('(data[@name="cpu_time"]/value/text())[1]', 'bigint') / 1000.,
+ logical_reads = (oa.c.value('(data[@name="logical_reads"]/value/text())[1]', 'bigint') * 8) / 1024.,
+ physical_reads = (oa.c.value('(data[@name="physical_reads"]/value/text())[1]', 'bigint') * 8) / 1024.,
+ writes_mb = (oa.c.value('(data[@name="writes"]/value/text())[1]', 'bigint') * 8) / 1024.,
+ row_count = oa.c.value('(data[@name="row_count"]/value/text())[1]', 'bigint')
+ FROM #human_events_xml AS xet
+ OUTER APPLY xet.human_events_xml.nodes('//event') AS oa(c)
+ WHERE oa.c.value('@name', 'sysname') = N'rpc_completed'
+ )
+ SELECT
+ pr.event_time,
+ pr.event_type,
+ pr.database_name,
+ pr.object_name,
+ statement =
+ (
+ SELECT
+ [processing-instruction(statement)] =
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ pr.statement COLLATE Latin1_General_BIN2,
+ NCHAR(31),N'?'),NCHAR(30),N'?'),NCHAR(29),N'?'),NCHAR(28),N'?'),NCHAR(27),N'?'),NCHAR(26),N'?'),NCHAR(25),N'?'),NCHAR(24),N'?'),NCHAR(23),N'?'),NCHAR(22),N'?'),
+ NCHAR(21),N'?'),NCHAR(20),N'?'),NCHAR(19),N'?'),NCHAR(18),N'?'),NCHAR(17),N'?'),NCHAR(16),N'?'),NCHAR(15),N'?'),NCHAR(14),N'?'),NCHAR(12),N'?'),
+ NCHAR(11),N'?'),NCHAR(8),N'?'),NCHAR(7),N'?'),NCHAR(6),N'?'),NCHAR(5),N'?'),NCHAR(4),N'?'),NCHAR(3),N'?'),NCHAR(2),N'?'),NCHAR(1),N'?'),NCHAR(0),N'?')
+ FOR XML
+ PATH(N''),
+ TYPE
+ ),
+ sql_text =
+ (
+ SELECT
+ [processing-instruction(sql_text)] =
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ pr.sql_text COLLATE Latin1_General_BIN2,
+ NCHAR(31),N'?'),NCHAR(30),N'?'),NCHAR(29),N'?'),NCHAR(28),N'?'),NCHAR(27),N'?'),NCHAR(26),N'?'),NCHAR(25),N'?'),NCHAR(24),N'?'),NCHAR(23),N'?'),NCHAR(22),N'?'),
+ NCHAR(21),N'?'),NCHAR(20),N'?'),NCHAR(19),N'?'),NCHAR(18),N'?'),NCHAR(17),N'?'),NCHAR(16),N'?'),NCHAR(15),N'?'),NCHAR(14),N'?'),NCHAR(12),N'?'),
+ NCHAR(11),N'?'),NCHAR(8),N'?'),NCHAR(7),N'?'),NCHAR(6),N'?'),NCHAR(5),N'?'),NCHAR(4),N'?'),NCHAR(3),N'?'),NCHAR(2),N'?'),NCHAR(1),N'?'),NCHAR(0),N'?')
+ FOR XML
+ PATH(N''),
+ TYPE
+ ),
+ pr.duration_ms,
+ pr.cpu_ms,
+ pr.logical_reads,
+ pr.physical_reads,
+ pr.writes_mb,
+ pr.row_count
+ FROM prepare_rpc AS pr
+ WHERE
+ (
+ pr.statement LIKE N'%sp[_]prepare%'
+ OR pr.statement LIKE N'%sp[_]prepexec%'
+ OR pr.statement LIKE N'%sp[_]execute%'
+ OR pr.statement LIKE N'%sp[_]unprepare%'
+ OR pr.statement LIKE N'%sp[_]cursor%'
+ OR pr.statement LIKE N'%sp[_]describe[_]undeclared[_]parameters%'
+ )
+ AND pr.statement NOT LIKE N'%sp[_]executesql%'
+ ORDER BY
+ pr.event_time
+ OPTION(RECOMPILE);
+ END;
END;
From 8a1b46fca8313ed983e98dcd583be8674d3dfe22 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 13 Jul 2026 09:43:07 -0400
Subject: [PATCH 04/60] sp_QuickieCache: read each plan cache DMV once, not
once per query_hash
sys.dm_exec_query_stats is a streaming view over the plan cache with
nothing seekable in it. The statement path picked its sample handles
with an UPDATE that CROSS APPLY'd back into the DMV correlated on
query_hash, which nested-loops: the optimizer spools the whole cache,
then rewinds, refilters and re-sorts every cached statement once per
collected hash, so the work grew with (hashes x cached statements). The
plan cache health checks scanned the same DMV four more times, each with
a per-row sys.dm_exec_plan_attributes call.
Materialize it once into #dm_exec_query_stats, apply the database
filters on the way in, and let every consumer read the temp table. The
sample handles now fall out of the statement aggregation itself (totals
rolled up, joined to the winning plan row), so the UPDATE is gone rather
than merely faster. 2x faster on a 22k-plan cache.
Fixes carried in the same change, each verified against a frozen cache
snapshot rather than two reads of a moving one:
- Sample plans could come from the wrong database. The old CROSS APPLY
carried no database or date predicates, so with @database_name set it
returned the most-executed plan for a hash from ANY database, then
showed that plan and its text under the filtered database's name.
Filtering at materialization makes that impossible.
- "Null value is eliminated by an aggregate" on every real run. The
plan-age buckets used COUNT_BIG(DISTINCT CASE WHEN ... THEN
plan_handle END), which hands COUNT a NULL for every plan outside the
window - so, any cache holding plans older than an hour. Recast as a
count over rows pre-deduplicated on MAX(creation_time), preserving the
original "any statement of this plan is in the window" semantics
exactly. creation_time is a STATEMENT property, not a plan property: a
statement that recompiles restamps its own while the batch keeps its
plan_handle, so rows sharing a plan_handle routinely disagree.
- Procedure, function and trigger stats are materialized too, into a
shared #dm_exec_object_stats. The three DMVs are shaped identically
across every column used, so one staging table and one aggregate
replace three near-identical paths. Staging also keeps the rollup and
the winning-plan lookup on one stable snapshot: reading a DMV twice in
a single statement is two independent reads of live plan cache memory,
and anything evicted in between would vanish from the results.
- Drops the "Optimize for Ad Hoc Workloads" suggestion from the
single-use plan finding; the Forced Parameterization advice stays.
Verified on SQL Server 2012, 2016, 2017, 2019, 2022 and 2025: installs
and runs clean in every mode with no errors and no warnings, and returns
byte-identical output to the previous version against a live
22,245-plan cache.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
sp_QuickieCache/sp_QuickieCache.sql | 1263 ++++++++++++++++-----------
1 file changed, 773 insertions(+), 490 deletions(-)
diff --git a/sp_QuickieCache/sp_QuickieCache.sql b/sp_QuickieCache/sp_QuickieCache.sql
index ed5cb633..9a36cab4 100644
--- a/sp_QuickieCache/sp_QuickieCache.sql
+++ b/sp_QuickieCache/sp_QuickieCache.sql
@@ -569,6 +569,204 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
RETURN;
END;
+ /*
+ ╔══════════════════════════════════════════════════╗
+ ║ Step 0: Materialize the query stats DMV ║
+ ╚══════════════════════════════════════════════════╝
+
+ sys.dm_exec_query_stats is a streaming view over the plan cache,
+ not a table. There is nothing in it to seek, so every reference to
+ it is a full scan of the cache stores, and the optimizer costs it
+ with a fixed row guess no matter how many plans are really cached.
+
+ Every consumer below used to read it directly: one full cache scan
+ for the plan age distribution, one for the duplicate hash summary,
+ two more for the duplicate plan detail, one for the statement
+ aggregation, and then — worst of all — a correlated CROSS APPLY
+ back into it to pick each collected hash's sample handles. Nothing
+ in the DMV is seekable, so that apply nested-loops: the optimizer
+ spools the whole thing and then rewinds, refilters, and re-sorts
+ every cached statement once per collected query_hash. The work grew
+ with (hashes × cached statements) rather than with the cache.
+
+ Read it once into a temp table instead: one pass over the cache,
+ one sys.dm_exec_plan_attributes call per row, and every query below
+ then runs against real cardinality and real statistics.
+
+ Only the database filters are applied here, because they are the
+ only ones every consumer shares. The query_hash, date, and
+ execution count filters stay with the queries that want them; the
+ health checks deliberately measure the whole cache rather than the
+ filtered slice of it.
+
+ The memory grant and spill columns are the only reason any of this
+ needs dynamic SQL. Version-gating them once, here, is what lets
+ every query below be static: on versions without them we store
+ NULL, so SUM() and MAX() produce the same zeros and NULLs that the
+ old version-gated column lists produced by omitting them.
+ */
+ DECLARE
+ @sql nvarchar(max) = N'';
+
+ CREATE TABLE
+ #dm_exec_query_stats
+ (
+ database_id integer NULL,
+ query_hash binary(8) NULL,
+ plan_handle varbinary(64) NULL,
+ sql_handle varbinary(64) NULL,
+ statement_start_offset integer NULL,
+ statement_end_offset integer NULL,
+ execution_count bigint NULL,
+ total_worker_time bigint NULL,
+ total_elapsed_time bigint NULL,
+ total_logical_reads bigint NULL,
+ total_logical_writes bigint NULL,
+ total_physical_reads bigint NULL,
+ total_rows bigint NULL,
+ min_rows bigint NULL,
+ max_rows bigint NULL,
+ min_worker_time bigint NULL,
+ max_worker_time bigint NULL,
+ min_physical_reads bigint NULL,
+ max_physical_reads bigint NULL,
+ min_elapsed_time bigint NULL,
+ max_elapsed_time bigint NULL,
+ max_dop bigint NULL,
+ max_grant_kb bigint NULL,
+ max_used_grant_kb bigint NULL,
+ total_spills bigint NULL,
+ max_spills bigint NULL,
+ creation_time datetime NULL,
+ last_execution_time datetime NULL
+ );
+
+ SELECT
+ @sql = N'
+INSERT
+ #dm_exec_query_stats
+WITH
+ (TABLOCK)
+(
+ database_id,
+ query_hash,
+ plan_handle,
+ sql_handle,
+ statement_start_offset,
+ statement_end_offset,
+ execution_count,
+ total_worker_time,
+ total_elapsed_time,
+ total_logical_reads,
+ total_logical_writes,
+ total_physical_reads,
+ total_rows,
+ min_rows,
+ max_rows,
+ min_worker_time,
+ max_worker_time,
+ min_physical_reads,
+ max_physical_reads,
+ min_elapsed_time,
+ max_elapsed_time,
+ max_dop,
+ max_grant_kb,
+ max_used_grant_kb,
+ total_spills,
+ max_spills,
+ creation_time,
+ last_execution_time
+)
+SELECT
+ database_id =
+ CONVERT(integer, pa.value),
+ qs.query_hash,
+ qs.plan_handle,
+ qs.sql_handle,
+ qs.statement_start_offset,
+ qs.statement_end_offset,
+ qs.execution_count,
+ qs.total_worker_time,
+ qs.total_elapsed_time,
+ qs.total_logical_reads,
+ qs.total_logical_writes,
+ qs.total_physical_reads,
+ qs.total_rows,
+ qs.min_rows,
+ qs.max_rows,
+ qs.min_worker_time,
+ qs.max_worker_time,
+ qs.min_physical_reads,
+ qs.max_physical_reads,
+ qs.min_elapsed_time,
+ qs.max_elapsed_time,
+ qs.max_dop,' +
+ CASE
+ WHEN @has_memory_grants = 1
+ THEN N'
+ max_grant_kb = ISNULL(qs.max_grant_kb, 0),
+ max_used_grant_kb = ISNULL(qs.max_used_grant_kb, 0),'
+ ELSE N'
+ max_grant_kb = NULL,
+ max_used_grant_kb = NULL,'
+ END +
+ CASE
+ WHEN @has_spills = 1
+ THEN N'
+ total_spills = ISNULL(qs.total_spills, 0),
+ max_spills = ISNULL(qs.max_spills, 0),'
+ ELSE N'
+ total_spills = NULL,
+ max_spills = NULL,'
+ END + N'
+ qs.creation_time,
+ qs.last_execution_time
+FROM sys.dm_exec_query_stats AS qs
+CROSS APPLY
+(
+ SELECT TOP (1)
+ value = pa.value
+ FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa
+ WHERE pa.attribute = N''dbid''
+) AS pa
+WHERE ISNULL(CONVERT(integer, pa.value), 0) < 32761' +
+ CASE
+ WHEN @ignore_system_databases = 1
+ THEN N'
+AND ISNULL(CONVERT(integer, pa.value), 0) NOT IN (1, 2, 3, 4)'
+ ELSE N''
+ END +
+ CASE
+ WHEN @database_id IS NOT NULL
+ THEN N'
+AND CONVERT(integer, pa.value) = @database_id'
+ ELSE N''
+ END + N'
+OPTION(RECOMPILE, MAXDOP 1);';
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Plan cache materialization SQL:', 0, 1) WITH NOWAIT;
+ RAISERROR(N'%s', 0, 1, @sql) WITH NOWAIT;
+ END;
+
+ EXECUTE sys.sp_executesql
+ @sql,
+ N'@database_id integer',
+ @database_id;
+
+ IF @debug = 1
+ BEGIN
+ DECLARE
+ @dmv_rows bigint;
+
+ SELECT
+ @dmv_rows = COUNT_BIG(*)
+ FROM #dm_exec_query_stats AS s;
+
+ RAISERROR(N'Plan cache rows materialized: %I64d', 0, 1, @dmv_rows) WITH NOWAIT;
+ END;
+
/*
╔══════════════════════════════════════════════════╗
║ Plan cache health analysis ║
@@ -597,11 +795,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
sys.dm_exec_cached_plans — the number the user actually sees in the
cache — so the finding reconciles with what they observe. The age
buckets are computed at plan grain (DISTINCT plan_handle) from
- sys.dm_exec_query_stats, the only source with per-plan compile
+ #dm_exec_query_stats, the only source with per-plan compile
times. Counting raw query_stats rows (statement grain) inflated
every number, because a multi-statement plan contributes one row
per statement; @total_plans is now distinct plans with execution
stats, the consistent denominator for the recency percentages.
+
+ The database filters were already applied when #dm_exec_query_stats
+ was populated, so there is nothing left to filter here. The date
+ filters are deliberately NOT applied: this measures the health of
+ the whole cache, not of the slice the caller asked to analyze.
*/
DECLARE
@total_plans bigint = 0,
@@ -631,47 +834,92 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id)
OPTION(RECOMPILE);
+ /*
+ The buckets are counted by first collapsing the statement-grained
+ rows down to one row per plan, then adding up the plans that fall in
+ each window.
+
+ The obvious way to write this is
+ COUNT_BIG(DISTINCT CASE WHEN ... THEN plan_handle END), and that is
+ what it used to be. Every plan OUTSIDE the window makes that CASE
+ return NULL, COUNT ignores it, and SQL Server says so: "Null value
+ is eliminated by an aggregate or other SET operation" — printed on
+ every run against any cache holding plans older than an hour, which
+ is every real cache. Counting 1s and 0s over a pre-deduplicated set
+ gets the same answers without ever handing an aggregate a NULL.
+
+ Bucket on the NEWEST compile, and ONLY on the newest compile.
+
+ creation_time is a property of the STATEMENT, not of the plan: when
+ a single statement inside a batch recompiles, it restamps its own
+ creation_time (and its own plan_generation_num) while the batch
+ keeps its plan_handle. So rows sharing a plan_handle routinely carry
+ different creation_times — days apart, on a cache with any recompile
+ activity at all.
+
+ That makes the choice of MIN vs MAX load-bearing rather than
+ cosmetic. COUNT_BIG(DISTINCT CASE ...) counted a plan as recent if
+ ANY of its rows landed in the window, which is MAX semantics.
+ Collapsing on MIN instead would ask whether the plan's OLDEST
+ statement is recent, silently under-reporting every bucket and
+ letting a cache that churns through statement-level recompiles —
+ exactly what this check exists to catch — report as healthy.
+
+ @oldest_plan_date still wants the true minimum, so the derived table
+ carries both ends.
+ */
SELECT
- @total_plans = COUNT_BIG(DISTINCT qs.plan_handle),
+ @total_plans = COUNT_BIG(*),
@plans_24h =
- COUNT_BIG
+ ISNULL
(
- DISTINCT
- CASE
- WHEN DATEDIFF(HOUR, qs.creation_time, GETDATE()) <= 24
- THEN qs.plan_handle
- END
+ SUM
+ (
+ CASE
+ WHEN DATEDIFF(HOUR, p.newest_compile, GETDATE()) <= 24
+ THEN 1
+ ELSE 0
+ END
+ ),
+ 0
),
@plans_4h =
- COUNT_BIG
+ ISNULL
(
- DISTINCT
- CASE
- WHEN DATEDIFF(HOUR, qs.creation_time, GETDATE()) <= 4
- THEN qs.plan_handle
- END
+ SUM
+ (
+ CASE
+ WHEN DATEDIFF(HOUR, p.newest_compile, GETDATE()) <= 4
+ THEN 1
+ ELSE 0
+ END
+ ),
+ 0
),
@plans_1h =
- COUNT_BIG
+ ISNULL
(
- DISTINCT
- CASE
- WHEN DATEDIFF(HOUR, qs.creation_time, GETDATE()) <= 1
- THEN qs.plan_handle
- END
+ SUM
+ (
+ CASE
+ WHEN DATEDIFF(HOUR, p.newest_compile, GETDATE()) <= 1
+ THEN 1
+ ELSE 0
+ END
+ ),
+ 0
),
- @oldest_plan_date = MIN(qs.creation_time)
- FROM sys.dm_exec_query_stats AS qs
- CROSS APPLY
+ @oldest_plan_date = MIN(p.oldest_compile)
+ FROM
(
- SELECT TOP (1)
- value = pa.value
- FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa
- WHERE pa.attribute = N'dbid'
- ) AS pa
- WHERE (@ignore_system_databases = 0 OR ISNULL(CONVERT(integer, pa.value), 0) NOT IN (1, 2, 3, 4))
- AND ISNULL(CONVERT(integer, pa.value), 0) < 32761
- AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id)
+ SELECT
+ s.plan_handle,
+ newest_compile = MAX(s.creation_time),
+ oldest_compile = MIN(s.creation_time)
+ FROM #dm_exec_query_stats AS s
+ GROUP BY
+ s.plan_handle
+ ) AS p
OPTION(RECOMPILE);
IF @total_plans > 0
@@ -736,9 +984,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Single-use plan bloat per database:
A high percentage of single-use adhoc/prepared plans suggests an
unparameterized ad hoc workload that bloats the plan cache and may
- benefit from Optimize for Ad Hoc Workloads and/or Forced
- Parameterization. Only surfaces databases where single-use plans
- exceed 10% of that database's cached compiled plans.
+ benefit from Forced Parameterization. Only surfaces databases where
+ single-use plans exceed 10% of that database's cached compiled plans.
Sourced from sys.dm_exec_cached_plans on purpose, NOT
sys.dm_exec_query_stats. query_stats is statement-grained and only
@@ -785,7 +1032,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
N' of ' + FORMAT(x.total_count, N'N0') +
N' cached plans (' +
CONVERT(nvarchar(10), x.single_use_pct) +
- N'%) are single-use adhoc or prepared plans. Consider Optimize for Ad Hoc Workloads, and Forced Parameterization if these are unparameterized ad hoc queries.'
+ N'%) are single-use adhoc or prepared plans. Consider Forced Parameterization if these are unparameterized ad hoc queries.'
FROM
(
SELECT
@@ -854,23 +1101,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
FROM
(
SELECT
- plan_count = COUNT_BIG(DISTINCT qs.plan_handle)
- FROM sys.dm_exec_query_stats AS qs
- CROSS APPLY
- (
- SELECT TOP (1)
- value = pa.value
- FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa
- WHERE pa.attribute = N'dbid'
- ) AS pa
- WHERE qs.query_hash <> 0x0000000000000000
- AND (@ignore_system_databases = 0 OR ISNULL(CONVERT(integer, pa.value), 0) NOT IN (1, 2, 3, 4))
- AND ISNULL(CONVERT(integer, pa.value), 0) < 32761
- AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id)
+ plan_count = COUNT_BIG(DISTINCT s.plan_handle)
+ FROM #dm_exec_query_stats AS s
+ WHERE s.query_hash <> 0x0000000000000000
GROUP BY
- qs.query_hash
+ s.query_hash
HAVING
- COUNT_BIG(DISTINCT qs.plan_handle) > 5
+ COUNT_BIG(DISTINCT s.plan_handle) > 5
) AS x
OPTION(RECOMPILE);
@@ -905,7 +1142,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THEN N'Massive duplicate plan compilation'
ELSE N'Notable duplicate plan compilation'
END,
- database_name = DB_NAME(CONVERT(integer, pa.value)),
+ database_name = DB_NAME(s2.database_id),
priority =
CASE
WHEN @pct_duplicate > 75
@@ -913,38 +1150,36 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ELSE 254
END,
details =
- FORMAT(COUNT_BIG(DISTINCT qs2.query_hash), N'N0') +
+ FORMAT(COUNT_BIG(DISTINCT s2.query_hash), N'N0') +
N' query hashes with 5+ plans, totaling ' +
- FORMAT(COUNT_BIG(DISTINCT qs2.plan_handle), N'N0') + N' plans. ' +
+ FORMAT(COUNT_BIG(DISTINCT s2.plan_handle), N'N0') + N' plans. ' +
N'Most likely unparameterized queries. SET option differences between sessions can also cause this. Consider Forced Parameterization.'
+ /*
+ Both sides now read the same filtered set, so the hashes counted
+ here are the same hashes @duplicate_hashes and @duplicate_plans
+ were computed from. The inner query used to scan the DMV with no
+ database predicate at all while the outer one was filtered, which
+ let a hash qualify on the strength of plans in databases the
+ caller had excluded.
+ */
FROM
(
SELECT
- qs.query_hash
- FROM sys.dm_exec_query_stats AS qs
- WHERE qs.query_hash <> 0x0000000000000000
+ s.query_hash
+ FROM #dm_exec_query_stats AS s
+ WHERE s.query_hash <> 0x0000000000000000
GROUP BY
- qs.query_hash
+ s.query_hash
HAVING
- COUNT_BIG(DISTINCT qs.plan_handle) > 5
+ COUNT_BIG(DISTINCT s.plan_handle) > 5
) AS x
- JOIN sys.dm_exec_query_stats AS qs2
- ON qs2.query_hash = x.query_hash
- CROSS APPLY
- (
- SELECT TOP (1)
- value = pa.value
- FROM sys.dm_exec_plan_attributes(qs2.plan_handle) AS pa
- WHERE pa.attribute = N'dbid'
- ) AS pa
- WHERE pa.value IS NOT NULL
- AND (@ignore_system_databases = 0 OR CONVERT(integer, pa.value) NOT IN (1, 2, 3, 4))
- AND CONVERT(integer, pa.value) < 32761
- AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id)
+ JOIN #dm_exec_query_stats AS s2
+ ON s2.query_hash = x.query_hash
+ WHERE s2.database_id IS NOT NULL
GROUP BY
- pa.value
+ s2.database_id
ORDER BY
- COUNT_BIG(DISTINCT qs2.plan_handle) DESC
+ COUNT_BIG(DISTINCT s2.plan_handle) DESC
OPTION(RECOMPILE);
END;
@@ -1085,170 +1320,238 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
sample_statement_end integer NULL
);
- DECLARE
- @sql nvarchar(max) = N'';
-
/*
╔══════════════════════════════════════════════════╗
║ Step 1a: Collect statement-level stats ║
- ║ (sys.dm_exec_query_stats, grouped by hash) ║
+ ║ (#dm_exec_query_stats, grouped by hash) ║
╚══════════════════════════════════════════════════╝
+
+ Two things happen here, and they are deliberately kept apart.
+
+ "totals" rolls the hash group up. "winner" picks the single plan
+ that represents it — the most-executed one — and that one row
+ supplies the database, both handles, and both offsets, so the text,
+ the offsets, and the plan always describe the same plan instead of
+ being MAX()'d independently out of different ones.
+
+ The winner could instead be folded into the aggregate as
+ MAX(CASE WHEN n = 1 THEN ... END). Don't. Every row that isn't the
+ winner feeds a NULL into those MAX()es, and SQL Server answers with
+ "Null value is eliminated by an aggregate or other SET operation" on
+ every run. Joining to the winner asks the same question without ever
+ handing an aggregate a NULL. The object path in Step 1b does the
+ same thing for the same reason.
+
+ Reading #dm_exec_query_stats twice — once for totals, once for the
+ winner — is safe precisely BECAUSE it is a temp table. It holds
+ still between the two reads. Do not repoint either side of this join
+ at sys.dm_exec_query_stats directly: a DMV gets no statement-level
+ snapshot, so the two sides would see two different plan caches and
+ the inner join would silently drop any hash that got evicted in
+ between.
+
+ This all used to be a second pass — an UPDATE that CROSS APPLY'd
+ sys.dm_exec_query_stats per collected query_hash to go find those
+ handles. Nothing in the DMV is seekable, so the apply nested-loops
+ over it: spool the whole cache, then rewind, refilter, and re-sort
+ it once per hash. It also carried none of the database or date
+ predicates, so with @database_name set it could hand back the
+ handles of a plan from a database the caller had explicitly
+ filtered out, and then show its text and its plan.
*/
+ INSERT
+ #query_stats
+ WITH
+ (TABLOCK)
+ (
+ query_type,
+ database_name,
+ query_hash,
+ plan_count,
+ total_executions,
+ total_cpu_ms,
+ total_duration_ms,
+ total_logical_reads,
+ total_logical_writes,
+ total_physical_reads,
+ total_rows,
+ total_grant_mb,
+ total_used_grant_mb,
+ total_spills,
+ max_grant_mb,
+ max_used_grant_mb,
+ max_spills,
+ max_dop,
+ min_rows,
+ max_rows,
+ min_cpu_ms,
+ max_cpu_ms,
+ min_physical_reads,
+ max_physical_reads,
+ min_duration_ms,
+ max_duration_ms,
+ oldest_plan_creation,
+ newest_plan_creation,
+ last_execution_time,
+ sample_sql_handle,
+ sample_plan_handle,
+ sample_statement_start,
+ sample_statement_end
+ )
SELECT
- @sql = N'
-INSERT
- #query_stats
-WITH
- (TABLOCK)
-(
- query_type,
- database_name,
- query_hash,
- plan_count,
- total_executions,
- total_cpu_ms,
- total_duration_ms,
- total_logical_reads,
- total_logical_writes,
- total_physical_reads,
- total_rows,' +
- CASE
- WHEN @has_memory_grants = 1
- THEN N'
- total_grant_mb,
- total_used_grant_mb,
- max_grant_mb,
- max_used_grant_mb,'
- ELSE N''
- END +
- CASE
- WHEN @has_spills = 1
- THEN N'
- total_spills,
- max_spills,'
- ELSE N''
- END + N'
- max_dop,
- min_rows,
- max_rows,
- min_cpu_ms,
- max_cpu_ms,
- min_physical_reads,
- max_physical_reads,
- min_duration_ms,
- max_duration_ms,
- oldest_plan_creation,
- newest_plan_creation,
- last_execution_time
-)
-SELECT
- query_type = ''Statement'',
- database_name =
- DB_NAME
- (
- CONVERT
- (
- integer,
- MAX(pa.value)
- )
- ),
- query_hash = qs.query_hash,
- plan_count = COUNT_BIG(DISTINCT qs.plan_handle),
- total_executions = SUM(qs.execution_count),
- total_cpu_ms = SUM(qs.total_worker_time) / 1000.0,
- total_duration_ms = SUM(qs.total_elapsed_time) / 1000.0,
- total_logical_reads = SUM(qs.total_logical_reads),
- total_logical_writes = SUM(qs.total_logical_writes),
- total_physical_reads = SUM(qs.total_physical_reads),
- total_rows = SUM(qs.total_rows),' +
- CASE
- WHEN @has_memory_grants = 1
- THEN N'
- total_grant_mb = SUM(ISNULL(qs.max_grant_kb, 0)) / 1024.0,
- total_used_grant_mb = SUM(ISNULL(qs.max_used_grant_kb, 0)) / 1024.0,
- max_grant_mb = MAX(ISNULL(qs.max_grant_kb, 0)) / 1024.0,
- max_used_grant_mb = MAX(ISNULL(qs.max_used_grant_kb, 0)) / 1024.0,'
- ELSE N''
- END +
- CASE
- WHEN @has_spills = 1
- THEN N'
- total_spills = SUM(ISNULL(qs.total_spills, 0)),
- max_spills = MAX(ISNULL(qs.max_spills, 0)),'
- ELSE N''
- END + N'
- max_dop = MAX(qs.max_dop),
- min_rows = MIN(qs.min_rows),
- max_rows = MAX(qs.max_rows),
- min_cpu_ms = MIN(qs.min_worker_time) / 1000.0,
- max_cpu_ms = MAX(qs.max_worker_time) / 1000.0,
- min_physical_reads = MIN(qs.min_physical_reads),
- max_physical_reads = MAX(qs.max_physical_reads),
- min_duration_ms = MIN(qs.min_elapsed_time) / 1000.0,
- max_duration_ms = MAX(qs.max_elapsed_time) / 1000.0,
- oldest_plan_creation = MIN(qs.creation_time),
- newest_plan_creation = MAX(qs.creation_time),
- last_execution_time = MAX(qs.last_execution_time)
-FROM sys.dm_exec_query_stats AS qs
-CROSS APPLY
-(
- SELECT TOP (1)
- value = pa.value
- FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa
- WHERE pa.attribute = N''dbid''
-) AS pa
-WHERE qs.query_hash <> 0x0000000000000000' +
- /* @minimum_execution_count is enforced ONLY in the HAVING
- SUM(execution_count) below — applying it per-row here
- filtered out individual plans whose single-plan execution_count
- was below the floor but whose group total was above it
- (think: a recompile-heavy query with many plans each run a
- few times that add up to a lot). Same reasoning applies to
- the procedure / function / trigger paths further down. */
- CASE
- WHEN @ignore_system_databases = 1
- THEN N'
-AND ISNULL(pa.value, 0) NOT IN (1, 2, 3, 4)
-AND ISNULL(pa.value, 0) < 32761'
- ELSE N''
- END +
- CASE
- WHEN @database_id IS NOT NULL
- THEN N'
-AND CONVERT(integer, pa.value) = @database_id'
- ELSE N''
- END +
- CASE
- WHEN @start_date IS NOT NULL
- THEN N'
-AND qs.creation_time >= @start_date'
- ELSE N''
- END +
- CASE
- WHEN @end_date IS NOT NULL
- THEN N'
-AND qs.creation_time < @end_date'
- ELSE N''
- END + N'
-GROUP BY
- qs.query_hash
-HAVING
- SUM(qs.execution_count) >= @minimum_execution_count
-OPTION(RECOMPILE, MAXDOP 1);';
-
- IF @debug = 1
- BEGIN
- RAISERROR(N'Statement aggregation SQL:', 0, 1) WITH NOWAIT;
- RAISERROR(N'%s', 0, 1, @sql) WITH NOWAIT;
- END;
-
- EXECUTE sys.sp_executesql
- @sql,
- N'@minimum_execution_count bigint, @database_id integer, @start_date datetime, @end_date datetime',
- @minimum_execution_count,
- @database_id,
- @start_date,
- @end_date;
+ query_type = 'Statement',
+ database_name = DB_NAME(winner.database_id),
+ query_hash = totals.query_hash,
+ plan_count = totals.plan_count,
+ total_executions = totals.total_executions,
+ total_cpu_ms = totals.total_cpu_ms,
+ total_duration_ms = totals.total_duration_ms,
+ total_logical_reads = totals.total_logical_reads,
+ total_logical_writes = totals.total_logical_writes,
+ total_physical_reads = totals.total_physical_reads,
+ total_rows = totals.total_rows,
+ total_grant_mb = totals.total_grant_mb,
+ total_used_grant_mb = totals.total_used_grant_mb,
+ total_spills = totals.total_spills,
+ max_grant_mb = totals.max_grant_mb,
+ max_used_grant_mb = totals.max_used_grant_mb,
+ max_spills = totals.max_spills,
+ max_dop = totals.max_dop,
+ min_rows = totals.min_rows,
+ max_rows = totals.max_rows,
+ min_cpu_ms = totals.min_cpu_ms,
+ max_cpu_ms = totals.max_cpu_ms,
+ min_physical_reads = totals.min_physical_reads,
+ max_physical_reads = totals.max_physical_reads,
+ min_duration_ms = totals.min_duration_ms,
+ max_duration_ms = totals.max_duration_ms,
+ oldest_plan_creation = totals.oldest_plan_creation,
+ newest_plan_creation = totals.newest_plan_creation,
+ last_execution_time = totals.last_execution_time,
+ sample_sql_handle = winner.sql_handle,
+ sample_plan_handle = winner.plan_handle,
+ sample_statement_start = winner.statement_start_offset,
+ sample_statement_end = winner.statement_end_offset
+ FROM
+ (
+ SELECT
+ s.query_hash,
+ plan_count = COUNT_BIG(DISTINCT s.plan_handle),
+ total_executions = SUM(s.execution_count),
+ total_cpu_ms = SUM(s.total_worker_time) / 1000.0,
+ total_duration_ms = SUM(s.total_elapsed_time) / 1000.0,
+ total_logical_reads = SUM(s.total_logical_reads),
+ total_logical_writes = SUM(s.total_logical_writes),
+ total_physical_reads = SUM(s.total_physical_reads),
+ total_rows = SUM(s.total_rows),
+ /*
+ On versions without the memory grant and spill columns these
+ are NULL for every row, and the CASE hands back the same 0s
+ and NULLs the old version-gated column lists produced by
+ omitting them: the totals are NOT NULL columns that defaulted
+ to 0, the maxes are nullable and stayed NULL.
+
+ The ISNULL inside each aggregate is not decoration. Without
+ it, SUM() and MAX() would skip NULLs on those versions and
+ SQL Server would warn about it on every run. Feeding them
+ zeros keeps them quiet, and the CASE — not the aggregate —
+ decides whether the answer is a value or a NULL.
+ */
+ total_grant_mb =
+ CASE
+ WHEN @has_memory_grants = 1
+ THEN SUM(ISNULL(s.max_grant_kb, 0)) / 1024.0
+ ELSE 0
+ END,
+ total_used_grant_mb =
+ CASE
+ WHEN @has_memory_grants = 1
+ THEN SUM(ISNULL(s.max_used_grant_kb, 0)) / 1024.0
+ ELSE 0
+ END,
+ total_spills =
+ CASE
+ WHEN @has_spills = 1
+ THEN SUM(ISNULL(s.total_spills, 0))
+ ELSE 0
+ END,
+ max_grant_mb =
+ CASE
+ WHEN @has_memory_grants = 1
+ THEN MAX(ISNULL(s.max_grant_kb, 0)) / 1024.0
+ END,
+ max_used_grant_mb =
+ CASE
+ WHEN @has_memory_grants = 1
+ THEN MAX(ISNULL(s.max_used_grant_kb, 0)) / 1024.0
+ END,
+ max_spills =
+ CASE
+ WHEN @has_spills = 1
+ THEN MAX(ISNULL(s.max_spills, 0))
+ END,
+ max_dop = MAX(s.max_dop),
+ min_rows = MIN(s.min_rows),
+ max_rows = MAX(s.max_rows),
+ min_cpu_ms = MIN(s.min_worker_time) / 1000.0,
+ max_cpu_ms = MAX(s.max_worker_time) / 1000.0,
+ min_physical_reads = MIN(s.min_physical_reads),
+ max_physical_reads = MAX(s.max_physical_reads),
+ min_duration_ms = MIN(s.min_elapsed_time) / 1000.0,
+ max_duration_ms = MAX(s.max_elapsed_time) / 1000.0,
+ oldest_plan_creation = MIN(s.creation_time),
+ newest_plan_creation = MAX(s.creation_time),
+ last_execution_time = MAX(s.last_execution_time)
+ FROM #dm_exec_query_stats AS s
+ /* @minimum_execution_count is enforced ONLY in the HAVING
+ SUM(execution_count) below — applying it per-row here
+ filtered out individual plans whose single-plan execution_count
+ was below the floor but whose group total was above it
+ (think: a recompile-heavy query with many plans each run a
+ few times that add up to a lot). Same reasoning applies to
+ the procedure / function / trigger paths further down.
+ The database filters were already applied when
+ #dm_exec_query_stats was populated. */
+ WHERE s.query_hash <> 0x0000000000000000
+ AND s.creation_time >= ISNULL(@start_date, s.creation_time)
+ AND s.creation_time < ISNULL(@end_date, DATEADD(DAY, 1, s.creation_time))
+ GROUP BY
+ s.query_hash
+ HAVING
+ SUM(s.execution_count) >= @minimum_execution_count
+ ) AS totals
+ JOIN
+ (
+ SELECT
+ s.query_hash,
+ s.database_id,
+ s.sql_handle,
+ s.plan_handle,
+ s.statement_start_offset,
+ s.statement_end_offset,
+ /*
+ Ranked after the WHERE clause, so the winner is the most
+ executed plan among the rows the caller actually asked for,
+ not the most executed plan in the entire cache. The filters
+ below have to stay in step with the ones above, or the winner
+ could come from a row the totals never counted.
+ */
+ n =
+ ROW_NUMBER() OVER
+ (
+ PARTITION BY
+ s.query_hash
+ ORDER BY
+ s.execution_count DESC
+ )
+ FROM #dm_exec_query_stats AS s
+ WHERE s.query_hash <> 0x0000000000000000
+ AND s.creation_time >= ISNULL(@start_date, s.creation_time)
+ AND s.creation_time < ISNULL(@end_date, DATEADD(DAY, 1, s.creation_time))
+ ) AS winner
+ ON winner.query_hash = totals.query_hash
+ AND winner.n = 1
+ OPTION(RECOMPILE, MAXDOP 1);
IF @debug = 1
BEGIN
@@ -1263,35 +1566,6 @@ OPTION(RECOMPILE, MAXDOP 1);';
RAISERROR(N'Statement query_hash groups collected: %I64d', 0, 1, @stmt_count) WITH NOWAIT;
END;
- /*
- Fix up sample handles and offsets so they come from
- the same plan row. MAX() in the GROUP BY can mismatch
- a sql_handle from one plan with offsets from another,
- producing clipped or blank query text.
- */
- UPDATE
- qs
- SET
- qs.sample_sql_handle = x.sql_handle,
- qs.sample_plan_handle = x.plan_handle,
- qs.sample_statement_start = x.statement_start_offset,
- qs.sample_statement_end = x.statement_end_offset
- FROM #query_stats AS qs
- CROSS APPLY
- (
- SELECT TOP (1)
- dqs.sql_handle,
- dqs.plan_handle,
- dqs.statement_start_offset,
- dqs.statement_end_offset
- FROM sys.dm_exec_query_stats AS dqs
- WHERE dqs.query_hash = qs.query_hash
- ORDER BY
- dqs.execution_count DESC
- ) AS x
- WHERE qs.query_type = 'Statement'
- OPTION(RECOMPILE);
-
/*
Link statement-level rows to their parent procedure
via sql_handle in dm_exec_procedure_stats.
@@ -1312,120 +1586,106 @@ OPTION(RECOMPILE, MAXDOP 1);';
/*
╔══════════════════════════════════════════════════╗
- ║ Step 1b: Collect procedure-level stats ║
- ║ (sys.dm_exec_procedure_stats) ║
+ ║ Step 1b: Collect object-level stats ║
+ ║ (procedures, functions, and triggers) ║
╚══════════════════════════════════════════════════╝
+
+ These three DMVs are materialized for the same reason
+ sys.dm_exec_query_stats is, plus one that is specific to the
+ totals + winner shape below.
+
+ A DMV is not a table and gets no statement-level snapshot. Reading
+ one twice in a single statement is two independent reads of live
+ plan cache memory, and the optimizer does exactly that here: the
+ totals side and the winner side each get their own scan operator.
+ If a plan is evicted between the two reads — on a churning cache,
+ which is the situation this proc exists to diagnose — the object
+ appears in totals, is missing from winner, and the inner join drops
+ it from the report entirely. No error, no warning, just a
+ stored procedure that quietly stops existing.
+
+ A temp table can be read twice safely, because it holds still. So
+ read each DMV exactly once, up front, and let both sides of the
+ join work from the same snapshot.
+
+ The three DMVs are shaped identically across every column used here,
+ so one staging table and one aggregate serve all of them; query_type
+ keeps them apart. The row filters are applied on the way in, which
+ is also why the totals and winner queries below need no WHERE clause
+ of their own — there is no pair of predicate lists that can drift
+ apart.
+ */
+ CREATE TABLE
+ #dm_exec_object_stats
+ (
+ query_type varchar(20) NOT NULL,
+ database_id integer NULL,
+ object_id integer NULL,
+ plan_handle varbinary(64) NULL,
+ sql_handle varbinary(64) NULL,
+ execution_count bigint NULL,
+ total_worker_time bigint NULL,
+ total_elapsed_time bigint NULL,
+ total_logical_reads bigint NULL,
+ total_logical_writes bigint NULL,
+ total_physical_reads bigint NULL,
+ cached_time datetime NULL,
+ last_execution_time datetime NULL
+ );
+
+ /*
+ @minimum_execution_count is enforced ONLY in the HAVING
+ SUM(execution_count) below, never as a per-row filter here —
+ applying it per row would drop individual plans whose single-plan
+ execution_count was below the floor but whose group total was above
+ it (a recompile-heavy object with many plans, each run a few times,
+ that add up to a lot). Same reasoning as the statement path.
*/
INSERT
- #query_stats
+ #dm_exec_object_stats
WITH
(TABLOCK)
(
query_type,
- database_name,
- object_name,
- plan_count,
- total_executions,
- total_cpu_ms,
- total_duration_ms,
+ database_id,
+ object_id,
+ plan_handle,
+ sql_handle,
+ execution_count,
+ total_worker_time,
+ total_elapsed_time,
total_logical_reads,
total_logical_writes,
total_physical_reads,
- oldest_plan_creation,
- newest_plan_creation,
- last_execution_time,
- sample_sql_handle,
- sample_plan_handle
+ cached_time,
+ last_execution_time
)
- /*
- sample_sql_handle and sample_plan_handle previously used
- MAX(ps.sql_handle) and MAX(ps.plan_handle) — each picked the
- lexicographic max independently, so the two values could come
- from different plan rows and produce a mismatched text/plan
- pair when retrieved downstream. ROW_NUMBER() OVER
- (PARTITION BY database_id, object_id ORDER BY execution_count
- DESC) in a derived table, then MAX(CASE WHEN n = 1 THEN ...)
- in the outer aggregate, pulls both handles from the SAME winner
- row. Single DMV scan + one sort + one aggregate — much lighter
- than CROSS APPLY-ing the DMV per group, which nested-loops
- poorly on busy servers.
- */
SELECT
query_type = 'Procedure',
- database_name = DB_NAME(r.database_id),
- object_name = OBJECT_SCHEMA_NAME(r.object_id, r.database_id) + N'.' + OBJECT_NAME(r.object_id, r.database_id),
- plan_count = COUNT_BIG(DISTINCT r.plan_handle),
- total_executions = SUM(r.execution_count),
- total_cpu_ms = SUM(r.total_worker_time) / 1000.0,
- total_duration_ms = SUM(r.total_elapsed_time) / 1000.0,
- total_logical_reads = SUM(r.total_logical_reads),
- total_logical_writes = SUM(r.total_logical_writes),
- total_physical_reads = SUM(r.total_physical_reads),
- oldest_plan_creation = MIN(r.cached_time),
- newest_plan_creation = MAX(r.cached_time),
- last_execution_time = MAX(r.last_execution_time),
- sample_sql_handle = MAX(CASE WHEN r.n = 1 THEN r.sql_handle END),
- sample_plan_handle = MAX(CASE WHEN r.n = 1 THEN r.plan_handle END)
- FROM
- (
- SELECT
- ps.database_id,
- ps.object_id,
- ps.plan_handle,
- ps.sql_handle,
- ps.execution_count,
- ps.total_worker_time,
- ps.total_elapsed_time,
- ps.total_logical_reads,
- ps.total_logical_writes,
- ps.total_physical_reads,
- ps.cached_time,
- ps.last_execution_time,
- n =
- ROW_NUMBER() OVER
- (
- PARTITION BY
- ps.database_id,
- ps.object_id
- ORDER BY
- ps.execution_count DESC
- )
- FROM sys.dm_exec_procedure_stats AS ps
- /* See Statement path comment re: why @minimum_execution_count
- is HAVING-only rather than a per-row pre-filter. */
- WHERE ps.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END
- AND ps.database_id < 32761
- AND ps.database_id = ISNULL(@database_id, ps.database_id)
- AND ps.cached_time >= ISNULL(@start_date, ps.cached_time)
- AND ps.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ps.cached_time))
- ) AS r
- GROUP BY
- r.database_id,
- r.object_id
- HAVING
- SUM(r.execution_count) >= @minimum_execution_count
+ ps.database_id,
+ ps.object_id,
+ ps.plan_handle,
+ ps.sql_handle,
+ ps.execution_count,
+ ps.total_worker_time,
+ ps.total_elapsed_time,
+ ps.total_logical_reads,
+ ps.total_logical_writes,
+ ps.total_physical_reads,
+ ps.cached_time,
+ ps.last_execution_time
+ FROM sys.dm_exec_procedure_stats AS ps
+ WHERE ps.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END
+ AND ps.database_id < 32761
+ AND ps.database_id = ISNULL(@database_id, ps.database_id)
+ AND ps.cached_time >= ISNULL(@start_date, ps.cached_time)
+ AND ps.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ps.cached_time))
OPTION(RECOMPILE, MAXDOP 1);
- IF @debug = 1
- BEGIN
- DECLARE
- @proc_count bigint;
-
- SELECT
- @proc_count = COUNT_BIG(*)
- FROM #query_stats AS qs
- WHERE qs.query_type = 'Procedure';
-
- RAISERROR(N'Procedure objects collected: %I64d', 0, 1, @proc_count) WITH NOWAIT;
- END;
-
/*
- ╔══════════════════════════════════════════════════╗
- ║ Step 1c: Collect function-level stats ║
- ║ (sys.dm_exec_function_stats) ║
- ╚══════════════════════════════════════════════════╝
-
- dm_exec_function_stats is available starting SQL Server 2016.
+ sys.dm_exec_function_stats is available starting SQL Server 2016, so
+ it has to be referenced from dynamic SQL to keep this procedure
+ compiling on older builds.
*/
IF EXISTS
(
@@ -1439,111 +1699,107 @@ OPTION(RECOMPILE, MAXDOP 1);';
SELECT
@sql = N'
INSERT
- #query_stats
+ #dm_exec_object_stats
WITH
(TABLOCK)
(
query_type,
- database_name,
- object_name,
- plan_count,
- total_executions,
- total_cpu_ms,
- total_duration_ms,
+ database_id,
+ object_id,
+ plan_handle,
+ sql_handle,
+ execution_count,
+ total_worker_time,
+ total_elapsed_time,
total_logical_reads,
total_logical_writes,
total_physical_reads,
- oldest_plan_creation,
- newest_plan_creation,
- last_execution_time,
- sample_sql_handle,
- sample_plan_handle
+ cached_time,
+ last_execution_time
)
-/* Same ROW_NUMBER + derived-table pattern as procedure path. */
SELECT
query_type = ''Function'',
- database_name = DB_NAME(r.database_id),
- object_name = OBJECT_SCHEMA_NAME(r.object_id, r.database_id) + N''.'' + OBJECT_NAME(r.object_id, r.database_id),
- plan_count = COUNT_BIG(DISTINCT r.plan_handle),
- total_executions = SUM(r.execution_count),
- total_cpu_ms = SUM(r.total_worker_time) / 1000.0,
- total_duration_ms = SUM(r.total_elapsed_time) / 1000.0,
- total_logical_reads = SUM(r.total_logical_reads),
- total_logical_writes = SUM(r.total_logical_writes),
- total_physical_reads = SUM(r.total_physical_reads),
- oldest_plan_creation = MIN(r.cached_time),
- newest_plan_creation = MAX(r.cached_time),
- last_execution_time = MAX(r.last_execution_time),
- sample_sql_handle = MAX(CASE WHEN r.n = 1 THEN r.sql_handle END),
- sample_plan_handle = MAX(CASE WHEN r.n = 1 THEN r.plan_handle END)
-FROM
-(
- SELECT
- fs.database_id,
- fs.object_id,
- fs.plan_handle,
- fs.sql_handle,
- fs.execution_count,
- fs.total_worker_time,
- fs.total_elapsed_time,
- fs.total_logical_reads,
- fs.total_logical_writes,
- fs.total_physical_reads,
- fs.cached_time,
- fs.last_execution_time,
- n =
- ROW_NUMBER() OVER
- (
- PARTITION BY
- fs.database_id,
- fs.object_id
- ORDER BY
- fs.execution_count DESC
- )
- FROM sys.dm_exec_function_stats AS fs
- /* See Statement path comment re: why @minimum_execution_count
- is HAVING-only rather than a per-row pre-filter. */
- WHERE fs.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END
- AND fs.database_id < 32761
- AND fs.database_id = ISNULL(@database_id, fs.database_id)
- AND fs.cached_time >= ISNULL(@start_date, fs.cached_time)
- AND fs.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, fs.cached_time))
-) AS r
-GROUP BY
- r.database_id,
- r.object_id
-HAVING
- SUM(r.execution_count) >= @minimum_execution_count
+ fs.database_id,
+ fs.object_id,
+ fs.plan_handle,
+ fs.sql_handle,
+ fs.execution_count,
+ fs.total_worker_time,
+ fs.total_elapsed_time,
+ fs.total_logical_reads,
+ fs.total_logical_writes,
+ fs.total_physical_reads,
+ fs.cached_time,
+ fs.last_execution_time
+FROM sys.dm_exec_function_stats AS fs
+WHERE fs.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END
+AND fs.database_id < 32761
+AND fs.database_id = ISNULL(@database_id, fs.database_id)
+AND fs.cached_time >= ISNULL(@start_date, fs.cached_time)
+AND fs.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, fs.cached_time))
OPTION(RECOMPILE, MAXDOP 1);';
+ IF @debug = 1
+ BEGIN
+ RAISERROR(N'Function stats materialization SQL:', 0, 1) WITH NOWAIT;
+ RAISERROR(N'%s', 0, 1, @sql) WITH NOWAIT;
+ END;
+
EXECUTE sys.sp_executesql
@sql,
- N'@minimum_execution_count bigint, @database_id integer, @ignore_system_databases bit, @start_date datetime, @end_date datetime',
- @minimum_execution_count,
+ N'@database_id integer, @ignore_system_databases bit, @start_date datetime, @end_date datetime',
@database_id,
@ignore_system_databases,
@start_date,
@end_date;
-
- IF @debug = 1
- BEGIN
- DECLARE
- @func_count bigint;
-
- SELECT
- @func_count = COUNT_BIG(*)
- FROM #query_stats AS qs
- WHERE qs.query_type = 'Function';
-
- RAISERROR(N'Function objects collected: %I64d', 0, 1, @func_count) WITH NOWAIT;
- END;
END;
+ INSERT
+ #dm_exec_object_stats
+ WITH
+ (TABLOCK)
+ (
+ query_type,
+ database_id,
+ object_id,
+ plan_handle,
+ sql_handle,
+ execution_count,
+ total_worker_time,
+ total_elapsed_time,
+ total_logical_reads,
+ total_logical_writes,
+ total_physical_reads,
+ cached_time,
+ last_execution_time
+ )
+ SELECT
+ query_type = 'Trigger',
+ ts.database_id,
+ ts.object_id,
+ ts.plan_handle,
+ ts.sql_handle,
+ ts.execution_count,
+ ts.total_worker_time,
+ ts.total_elapsed_time,
+ ts.total_logical_reads,
+ ts.total_logical_writes,
+ ts.total_physical_reads,
+ ts.cached_time,
+ ts.last_execution_time
+ FROM sys.dm_exec_trigger_stats AS ts
+ WHERE ts.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END
+ AND ts.database_id < 32761
+ AND ts.database_id = ISNULL(@database_id, ts.database_id)
+ AND ts.cached_time >= ISNULL(@start_date, ts.cached_time)
+ AND ts.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ts.cached_time))
+ OPTION(RECOMPILE, MAXDOP 1);
+
/*
- ╔══════════════════════════════════════════════════╗
- ║ Step 1d: Collect trigger-level stats ║
- ║ (sys.dm_exec_trigger_stats) ║
- ╚══════════════════════════════════════════════════╝
+ One aggregate for all three object types. Same shape as the
+ statement path: totals rolls the object up, winner picks the
+ most-executed plan to represent it, and both handles come from that
+ one row so the text and the plan always describe the same plan.
*/
INSERT
#query_stats
@@ -1566,73 +1822,100 @@ OPTION(RECOMPILE, MAXDOP 1);';
sample_sql_handle,
sample_plan_handle
)
- /* Same ROW_NUMBER + derived-table pattern as procedure/function paths. */
SELECT
- query_type = 'Trigger',
- database_name = DB_NAME(r.database_id),
- object_name = OBJECT_SCHEMA_NAME(r.object_id, r.database_id) + N'.' + OBJECT_NAME(r.object_id, r.database_id),
- plan_count = COUNT_BIG(DISTINCT r.plan_handle),
- total_executions = SUM(r.execution_count),
- total_cpu_ms = SUM(r.total_worker_time) / 1000.0,
- total_duration_ms = SUM(r.total_elapsed_time) / 1000.0,
- total_logical_reads = SUM(r.total_logical_reads),
- total_logical_writes = SUM(r.total_logical_writes),
- total_physical_reads = SUM(r.total_physical_reads),
- oldest_plan_creation = MIN(r.cached_time),
- newest_plan_creation = MAX(r.cached_time),
- last_execution_time = MAX(r.last_execution_time),
- sample_sql_handle = MAX(CASE WHEN r.n = 1 THEN r.sql_handle END),
- sample_plan_handle = MAX(CASE WHEN r.n = 1 THEN r.plan_handle END)
+ query_type = totals.query_type,
+ database_name = DB_NAME(totals.database_id),
+ object_name =
+ OBJECT_SCHEMA_NAME(totals.object_id, totals.database_id) +
+ N'.' +
+ OBJECT_NAME(totals.object_id, totals.database_id),
+ plan_count = totals.plan_count,
+ total_executions = totals.total_executions,
+ total_cpu_ms = totals.total_cpu_ms,
+ total_duration_ms = totals.total_duration_ms,
+ total_logical_reads = totals.total_logical_reads,
+ total_logical_writes = totals.total_logical_writes,
+ total_physical_reads = totals.total_physical_reads,
+ oldest_plan_creation = totals.oldest_plan_creation,
+ newest_plan_creation = totals.newest_plan_creation,
+ last_execution_time = totals.last_execution_time,
+ sample_sql_handle = winner.sql_handle,
+ sample_plan_handle = winner.plan_handle
FROM
(
SELECT
- ts.database_id,
- ts.object_id,
- ts.plan_handle,
- ts.sql_handle,
- ts.execution_count,
- ts.total_worker_time,
- ts.total_elapsed_time,
- ts.total_logical_reads,
- ts.total_logical_writes,
- ts.total_physical_reads,
- ts.cached_time,
- ts.last_execution_time,
+ os.query_type,
+ os.database_id,
+ os.object_id,
+ plan_count = COUNT_BIG(DISTINCT os.plan_handle),
+ total_executions = SUM(os.execution_count),
+ total_cpu_ms = SUM(os.total_worker_time) / 1000.0,
+ total_duration_ms = SUM(os.total_elapsed_time) / 1000.0,
+ total_logical_reads = SUM(os.total_logical_reads),
+ total_logical_writes = SUM(os.total_logical_writes),
+ total_physical_reads = SUM(os.total_physical_reads),
+ oldest_plan_creation = MIN(os.cached_time),
+ newest_plan_creation = MAX(os.cached_time),
+ last_execution_time = MAX(os.last_execution_time)
+ FROM #dm_exec_object_stats AS os
+ GROUP BY
+ os.query_type,
+ os.database_id,
+ os.object_id
+ HAVING
+ SUM(os.execution_count) >= @minimum_execution_count
+ ) AS totals
+ JOIN
+ (
+ SELECT
+ os.query_type,
+ os.database_id,
+ os.object_id,
+ os.sql_handle,
+ os.plan_handle,
n =
ROW_NUMBER() OVER
(
PARTITION BY
- ts.database_id,
- ts.object_id
+ os.query_type,
+ os.database_id,
+ os.object_id
ORDER BY
- ts.execution_count DESC
+ os.execution_count DESC
)
- FROM sys.dm_exec_trigger_stats AS ts
- /* See Statement path comment re: why @minimum_execution_count
- is HAVING-only rather than a per-row pre-filter. */
- WHERE ts.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END
- AND ts.database_id < 32761
- AND ts.database_id = ISNULL(@database_id, ts.database_id)
- AND ts.cached_time >= ISNULL(@start_date, ts.cached_time)
- AND ts.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ts.cached_time))
- ) AS r
- GROUP BY
- r.database_id,
- r.object_id
- HAVING
- SUM(r.execution_count) >= @minimum_execution_count
+ FROM #dm_exec_object_stats AS os
+ ) AS winner
+ ON winner.query_type = totals.query_type
+ AND winner.database_id = totals.database_id
+ AND winner.object_id = totals.object_id
+ AND winner.n = 1
OPTION(RECOMPILE, MAXDOP 1);
IF @debug = 1
BEGIN
DECLARE
+ @proc_count bigint,
+ @func_count bigint,
@trig_count bigint;
+ /*
+ ISNULL because SUM over ZERO rows returns NULL, not 0 — the
+ ELSE 0 only guards against NULL inputs, not against #query_stats
+ being empty, which it is whenever the filters match nothing.
+ Without this these print "(null)" where the three separate
+ COUNT_BIG(*) reads they replaced printed "0".
+ */
SELECT
- @trig_count = COUNT_BIG(*)
- FROM #query_stats AS qs
- WHERE qs.query_type = 'Trigger';
+ @proc_count =
+ ISNULL(SUM(CASE WHEN qs.query_type = 'Procedure' THEN 1 ELSE 0 END), 0),
+ @func_count =
+ ISNULL(SUM(CASE WHEN qs.query_type = 'Function' THEN 1 ELSE 0 END), 0),
+ @trig_count =
+ ISNULL(SUM(CASE WHEN qs.query_type = 'Trigger' THEN 1 ELSE 0 END), 0)
+ FROM #query_stats AS qs;
+ RAISERROR(N'Procedure objects collected: %I64d', 0, 1, @proc_count) WITH NOWAIT;
+ RAISERROR(N'Function objects collected: %I64d', 0, 1, @func_count) WITH NOWAIT;
RAISERROR(N'Trigger objects collected: %I64d', 0, 1, @trig_count) WITH NOWAIT;
END;
From 02c74c36734a5d64b450784a586f2dc76772f658 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 13 Jul 2026 14:07:37 -0400
Subject: [PATCH 05/60] sp_HealthParser: fix ten verified defects across
collection, parsing, and logging
A multi-agent bug hunt with adversarial verification and live-server
validation found ten real defects; every fix here was reproduced
against SQL Server 2012-2025 before and after.
Collection and routing:
- The scheduler section had never returned a row. Both collection
paths filtered on 'scheduler_monitor_system_health', but the event
is named scheduler_monitor_system_health_ring_buffer_recorded -
zero matches out of 60k+ events on live servers. The parse also
read fields the event has never carried (scheduler_id, status,
is_online, ...), so the section is rebuilt around its real payload:
sql_cpu_utilization, other_process_cpu, system_idle,
memory_utilization, page_faults, working_set_delta_mb, with
@warnings_only flagging pegged SQL CPU, heavy other-process CPU,
or a trimmed working set. Old-shape log tables are dropped and
recreated on sight - provably lossless, since the name bug meant
nothing was ever inserted into them.
- The @what_to_check routing table and its readers had drifted:
'cpu' never collected sp_server_diagnostics_component_result (so
worker-thread and pending-task findings only appeared under 'all'),
'memory' missed it on the file-target path, the ring-buffer path
missed it for 'cpu' and 'waits', and the scheduler output ran under
'system' while collection only fired under 'cpu'. All aligned.
Parsing:
- error_reported carries database_id as an action, not a data
element; reading the data axis left database_name and database_id
NULL for every severe error. Now read from the action axis.
- Blocked-process and deadlock query_text went blank when a stored
procedure's database or object no longer resolves - the unguarded
OBJECT_SCHEMA_NAME + OBJECT_NAME concatenation nulled the whole
column. Both sites now fall back to the raw inputbuf text, which
still exposes the database and object ids.
Logging:
- QUOTENAME'd table names were re-embedded inside single-quoted
string literals (14 RAISERROR messages and the OBJECT_ID probe
template) without doubling quotes, so a legal apostrophe in
@log_database_name / @log_schema_name / @log_table_name_prefix
killed the entire logging run with a compile error. Literal-position
embeds now double their quotes; the probe template gained a
{table_check_literal} placeholder so the identifier and literal
positions no longer share a value.
- @log_to_table = 1 now honors its documented log-instead-of-return
contract: the blocking/deadlock no-results messages and the
available-plans result sets are suppressed while logging.
- The @log_table_* holders were sysname but store fully-qualified,
QUOTENAME'd three-part names, silently truncating long-but-legal
names; widened to nvarchar(776). A prefix over 111 characters now
fails loudly up front instead of QUOTENAME returning NULL and the
table creation becoming a silent no-op.
Verified: installs and runs clean on SQL Server 2012, 2016, 2017,
2019, 2022, and 2025 across all @what_to_check modes and both
targets; the scheduler section returns and logs real data for the
first time; a severe error raised in a user database now names that
database; an apostrophe-bearing prefix creates all 14 log tables;
@log_retention_days = 0 retains rows; logging runs return zero
result sets. A second adversarial review pass of these fixes
returned no findings.
Co-Authored-By: Claude Fable 5
---
sp_HealthParser/sp_HealthParser.sql | 687 ++++++++++++++++++----------
1 file changed, 456 insertions(+), 231 deletions(-)
diff --git a/sp_HealthParser/sp_HealthParser.sql b/sp_HealthParser/sp_HealthParser.sql
index c1a0c740..b332c5f8 100644
--- a/sp_HealthParser/sp_HealthParser.sql
+++ b/sp_HealthParser/sp_HealthParser.sql
@@ -253,21 +253,29 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@temp_table sysname,
@insert_list sysname,
@collection_sql nvarchar(MAX),
- /*Log to table stuff*/
- @log_table_significant_waits sysname,
- @log_table_waits_by_count sysname,
- @log_table_waits_by_duration sysname,
- @log_table_io_issues sysname,
- @log_table_cpu_tasks sysname,
- @log_table_memory_conditions sysname,
- @log_table_memory_broker sysname,
- @log_table_memory_node_oom sysname,
- @log_table_system_health sysname,
- @log_table_scheduler_issues sysname,
- @log_table_severe_errors sysname,
- @log_table_pending_tasks sysname,
- @log_table_blocking sysname,
- @log_table_deadlocks sysname,
+ /*
+ Log to table stuff.
+
+ These hold fully-qualified, QUOTENAME'd three-part names, not
+ single identifiers — each part can quote out to 258 characters
+ (128 doubled brackets plus delimiters), so three parts and two
+ dots need 776. sysname silently truncated long-but-legal names
+ and the resulting CREATE TABLE failed.
+ */
+ @log_table_significant_waits nvarchar(776),
+ @log_table_waits_by_count nvarchar(776),
+ @log_table_waits_by_duration nvarchar(776),
+ @log_table_io_issues nvarchar(776),
+ @log_table_cpu_tasks nvarchar(776),
+ @log_table_memory_conditions nvarchar(776),
+ @log_table_memory_broker nvarchar(776),
+ @log_table_memory_node_oom nvarchar(776),
+ @log_table_system_health nvarchar(776),
+ @log_table_scheduler_issues nvarchar(776),
+ @log_table_severe_errors nvarchar(776),
+ @log_table_pending_tasks nvarchar(776),
+ @log_table_blocking nvarchar(776),
+ @log_table_deadlocks nvarchar(776),
@cleanup_date datetime2(7),
@check_sql nvarchar(MAX) = N'',
@create_sql nvarchar(MAX) = N'',
@@ -391,8 +399,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
{cross_apply}
OPTION(RECOMPILE);
',
+ /*
+ {table_check_literal} and {table_check} carry the same table
+ name, but one lands inside a quoted string and the other is an
+ identifier. A name containing a single quote (legal in delimited
+ identifiers) is fine in the identifier position and fatal in the
+ literal one unless its quotes are doubled — so the literal
+ placeholder gets the quote-doubled value at every substitution
+ site, and they cannot share a placeholder.
+ */
@mdsql_template = N'
- IF OBJECT_ID(''{table_check}'', ''U'') IS NOT NULL
+ IF OBJECT_ID(''{table_check_literal}'', ''U'') IS NOT NULL
BEGIN
SELECT
@max_event_time =
@@ -522,6 +539,19 @@ AND ca.utc_timestamp < @end_date';
ELSE @log_retention_days
END;
+ /*
+ QUOTENAME returns NULL for any input over 128 characters, and a
+ NULL name turns @create_sql NULL, which sys.sp_executesql runs
+ as a silent no-op — no table, no error, no logging. The longest
+ suffix appended to the prefix is _SignificantWaits (17), so the
+ prefix must leave room for it. Fail loudly up front instead.
+ */
+ IF LEN(@log_table_name_prefix) > 111
+ BEGIN
+ RAISERROR('@log_table_name_prefix is limited to 111 characters, so the longest table name suffix (_SignificantWaits) still fits in an identifier. Logging will be disabled.', 11, 1) WITH NOWAIT;
+ RETURN;
+ END;
+
/* Validate database exists */
IF NOT EXISTS
(
@@ -638,7 +668,7 @@ AND ca.utc_timestamp < @end_date';
session_id integer NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for significant waits logging.'', 0, 1, ''' + @log_table_significant_waits + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for significant waits logging.'', 0, 1, ''' + REPLACE(@log_table_significant_waits, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -674,7 +704,7 @@ AND ca.utc_timestamp < @end_date';
max_wait_time_ms nvarchar(30) NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by count logging.'', 0, 1, ''' + @log_table_waits_by_count + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by count logging.'', 0, 1, ''' + REPLACE(@log_table_waits_by_count, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -709,7 +739,7 @@ AND ca.utc_timestamp < @end_date';
max_wait_time_ms nvarchar(30) NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by duration logging.'', 0, 1, ''' + @log_table_waits_by_duration + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by duration logging.'', 0, 1, ''' + REPLACE(@log_table_waits_by_duration, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -747,7 +777,7 @@ AND ca.utc_timestamp < @end_date';
longestPendingRequests_filePath nvarchar(500) NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for IO issues logging.'', 0, 1, ''' + @log_table_io_issues + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for IO issues logging.'', 0, 1, ''' + REPLACE(@log_table_io_issues, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -789,7 +819,7 @@ AND ca.utc_timestamp < @end_date';
didBlockingOccur bit NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU tasks logging.'', 0, 1, ''' + @log_table_cpu_tasks + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU tasks logging.'', 0, 1, ''' + REPLACE(@log_table_cpu_tasks, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -852,7 +882,7 @@ AND ca.utc_timestamp < @end_date';
last_os_error bigint NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory conditions logging.'', 0, 1, ''' + @log_table_memory_conditions + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory conditions logging.'', 0, 1, ''' + REPLACE(@log_table_memory_conditions, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -896,7 +926,7 @@ AND ca.utc_timestamp < @end_date';
notification nvarchar(256) NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory broker logging.'', 0, 1, ''' + @log_table_memory_broker + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory broker logging.'', 0, 1, ''' + REPLACE(@log_table_memory_broker, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -955,7 +985,7 @@ AND ca.utc_timestamp < @end_date';
is_process_virtual_memory_low nvarchar(10) NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory node OOM logging.'', 0, 1, ''' + @log_table_memory_node_oom + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory node OOM logging.'', 0, 1, ''' + REPLACE(@log_table_memory_node_oom, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1002,7 +1032,7 @@ AND ca.utc_timestamp < @end_date';
BadPagesFixed bigint NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for system health logging.'', 0, 1, ''' + @log_table_system_health + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for system health logging.'', 0, 1, ''' + REPLACE(@log_table_system_health, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1016,6 +1046,31 @@ AND ca.utc_timestamp < @end_date';
/* Create SchedulerIssues table if it doesn't exist */
SET @create_sql = N'
+ /*
+ Old versions of this table were shaped around per-scheduler
+ columns (scheduler_id, status, ...) that the scheduler event
+ never actually carried, so nothing was ever inserted into
+ them — the collection filtered on an event name that does
+ not exist. That makes dropping an old-shape table lossless,
+ and it has to happen or the reshaped INSERT fails against it.
+ */
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM ' + QUOTENAME(@log_database_name) + N'.sys.tables AS t
+ JOIN ' + QUOTENAME(@log_database_name) + N'.sys.schemas AS s
+ ON s.schema_id = t.schema_id
+ JOIN ' + QUOTENAME(@log_database_name) + N'.sys.columns AS c
+ ON c.object_id = t.object_id
+ WHERE t.name = @table_name + N''_SchedulerIssues''
+ AND s.name = @schema_name
+ AND c.name = N''scheduler_id''
+ )
+ BEGIN
+ DROP TABLE ' + @log_table_scheduler_issues + N';
+ END;
+
IF NOT EXISTS
(
SELECT
@@ -1032,17 +1087,15 @@ AND ca.utc_timestamp < @end_date';
id bigint IDENTITY,
collection_time datetime2(7) NOT NULL DEFAULT SYSDATETIME(),
event_time datetime2(7) NULL,
- scheduler_id integer NULL,
- cpu_id integer NULL,
- status nvarchar(256) NULL,
- is_online bit NULL,
- is_runnable bit NULL,
- is_running bit NULL,
- non_yielding_time_ms nvarchar(30) NULL,
- thread_quantum_ms nvarchar(30) NULL,
+ sql_cpu_utilization integer NULL,
+ other_process_cpu integer NULL,
+ system_idle integer NULL,
+ memory_utilization integer NULL,
+ page_faults nvarchar(30) NULL,
+ working_set_delta_mb decimal(19, 2) NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for scheduler issues logging.'', 0, 1, ''' + @log_table_scheduler_issues + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for scheduler issues logging.'', 0, 1, ''' + REPLACE(@log_table_scheduler_issues, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1080,7 +1133,7 @@ AND ca.utc_timestamp < @end_date';
database_id integer NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for severe errors logging.'', 0, 1, ''' + @log_table_severe_errors + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for severe errors logging.'', 0, 1, ''' + REPLACE(@log_table_severe_errors, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1119,7 +1172,7 @@ AND ca.utc_timestamp < @end_date';
entry_point_count bigint NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for pending tasks logging.'', 0, 1, ''' + @log_table_pending_tasks + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for pending tasks logging.'', 0, 1, ''' + REPLACE(@log_table_pending_tasks, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1186,7 +1239,7 @@ AND ca.utc_timestamp < @end_date';
blocked_process_report xml NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for blocking logging.'', 0, 1, ''' + @log_table_blocking + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for blocking logging.'', 0, 1, ''' + REPLACE(@log_table_blocking, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1240,7 +1293,7 @@ AND ca.utc_timestamp < @end_date';
deadlock_graph xml NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for deadlocks logging.'', 0, 1, ''' + @log_table_deadlocks + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for deadlocks logging.'', 0, 1, ''' + REPLACE(@log_table_deadlocks, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1380,15 +1433,35 @@ AND ca.utc_timestamp < @end_date';
END
FROM
(
+ /*
+ Every area an output section reads under must have a row here for
+ every event that section consumes, or that section silently returns
+ nothing for that @what_to_check value — the readers do not collect
+ for themselves. Multiple rows may share a temp_table safely: the
+ collection cursor marks the whole table collected after its first
+ pass, so it is still read exactly once.
+
+ The scheduler event's real name is
+ scheduler_monitor_system_health_ring_buffer_recorded — the short
+ name this used to filter on matches nothing, on any version, so the
+ scheduler section had never returned a single row. The scheduler
+ output runs under 'system' and 'cpu', and the CPU worker-thread,
+ pending-task, and memory sections all read
+ sp_server_diagnostics_component_result, so 'cpu' and 'memory' need
+ rows for it too.
+ */
VALUES
- ('cpu', 'scheduler_monitor_system_health', '#scheduler_monitor', 'scheduler_monitor'),
+ ('cpu', 'scheduler_monitor_system_health_ring_buffer_recorded', '#scheduler_monitor', 'scheduler_monitor'),
+ ('cpu', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'),
('disk', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'),
('locking', 'xml_deadlock_report', '#xml_deadlock_report', 'xml_deadlock_report'),
('locking', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'),
('waits', 'wait_info', '#wait_info', 'wait_info'),
('waits', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'),
+ ('system', 'scheduler_monitor_system_health_ring_buffer_recorded', '#scheduler_monitor', 'scheduler_monitor'),
('system', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'),
('system', 'error_reported', '#error_reported', 'error_reported'),
+ ('memory', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'),
('memory', 'memory_broker_ring_buffer_recorded', '#memory_broker', 'memory_broker'),
('memory', 'memory_node_oom_ring_buffer_recorded', '#memory_node_oom', 'memory_node_oom')
) AS v(area_name, object_name, temp_table, insert_list);
@@ -1736,7 +1809,15 @@ AND ca.utc_timestamp < @end_date';
WHERE e.x.exist('@name[.= "wait_info"]') = 1
OPTION(RECOMPILE);
END;
- IF @what_to_check IN ('all', 'disk', 'locking', 'system', 'memory')
+ /*
+ 'cpu' and 'waits' belong here too: the worker-thread and
+ pending-task sections read this table under 'cpu', and the
+ waits-by-count/duration sections read it under 'waits' — the
+ file-target routing table collects it for both, and this guard
+ has to stay in step or the ring-buffer path silently returns
+ nothing for those checks.
+ */
+ IF @what_to_check IN ('all', 'cpu', 'disk', 'locking', 'system', 'memory', 'waits')
BEGIN
IF @debug = 1
BEGIN
@@ -1806,7 +1887,7 @@ AND ca.utc_timestamp < @end_date';
e.x.query('.')
FROM #ring_buffer AS rb
CROSS APPLY rb.ring_buffer.nodes('/event') AS e(x)
- WHERE e.x.exist('@name[.= "scheduler_monitor_system_health"]') = 1
+ WHERE e.x.exist('@name[.= "scheduler_monitor_system_health_ring_buffer_recorded"]') = 1
OPTION(RECOMPILE);
END;
@@ -2092,7 +2173,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_significant_waits, N'''', N'''''')
+ ),
'{table_check}',
@log_table_significant_waits
),
@@ -2363,7 +2449,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_waits_by_count, N'''', N'''''')
+ ),
'{table_check}',
@log_table_waits_by_count
),
@@ -2653,7 +2744,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_waits_by_duration, N'''', N'''''')
+ ),
'{table_check}',
@log_table_waits_by_duration
),
@@ -2864,7 +2960,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_io_issues, N'''', N'''''')
+ ),
'{table_check}',
@log_table_io_issues
),
@@ -3063,7 +3164,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_cpu_tasks, N'''', N'''''')
+ ),
'{table_check}',
@log_table_cpu_tasks
),
@@ -3289,7 +3395,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_pending_tasks, N'''', N'''''')
+ ),
'{table_check}',
@log_table_pending_tasks
),
@@ -3514,7 +3625,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_memory_conditions, N'''', N'''''')
+ ),
'{table_check}',
@log_table_memory_conditions
),
@@ -3814,7 +3930,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_memory_broker, N'''', N'''''')
+ ),
'{table_check}',
@log_table_memory_broker
),
@@ -4209,7 +4330,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_memory_node_oom, N'''', N'''''')
+ ),
'{table_check}',
@log_table_memory_node_oom
),
@@ -4424,7 +4550,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_system_health, N'''', N'''''')
+ ),
'{table_check}',
@log_table_system_health
),
@@ -4512,6 +4643,23 @@ AND ca.utc_timestamp < @end_date';
RAISERROR('Parsing scheduler monitor data', 0, 0) WITH NOWAIT;
END;
+ /*
+ The scheduler_monitor_system_health_ring_buffer_recorded event
+ is a utilization sample, not a per-scheduler status record. Its
+ data fields are process_utilization (SQL Server's CPU%),
+ system_idle (idle CPU%), memory_utilization (percent of
+ committed memory in the working set — 100 is healthy, low means
+ the working set got trimmed), page_faults, and
+ working_set_delta (bytes). The previous version of this section
+ parsed scheduler_id / status / is_online and friends, which
+ this event has never carried — every column came back NULL,
+ and its WARNING filter matched nothing, ever.
+
+ A sample is a warning when SQL Server's CPU is pinned, when
+ other processes are eating half the box, or when the working
+ set has been cut down — the classic external memory pressure
+ signal.
+ */
SELECT
event_time =
DATEADD
@@ -4525,19 +4673,38 @@ AND ca.utc_timestamp < @end_date';
),
w.x.value('@timestamp', 'datetime2')
),
- scheduler_id = w.x.value('(data[@name="scheduler_id"]/value)[1]', 'integer'),
- cpu_id = w.x.value('(data[@name="cpu_id"]/value)[1]', 'integer'),
- status = w.x.value('(data[@name="status"]/text)[1]', 'nvarchar(256)'),
- is_online = w.x.value('(data[@name="is_online"]/value)[1]', 'bit'),
- is_runnable = w.x.value('(data[@name="is_runnable"]/value)[1]', 'bit'),
- is_running = w.x.value('(data[@name="is_running"]/value)[1]', 'bit'),
- non_yielding_time_ms = w.x.value('(data[@name="non_yielding_time"]/value)[1]', 'bigint'),
- thread_quantum_ms = w.x.value('(data[@name="thread_quantum"]/value)[1]', 'bigint'),
+ sql_cpu_utilization = v.sql_cpu_utilization,
+ other_process_cpu =
+ (100 - v.sql_cpu_utilization - v.system_idle),
+ system_idle = v.system_idle,
+ memory_utilization = v.memory_utilization,
+ page_faults = v.page_faults,
+ working_set_delta_mb = v.working_set_delta_mb,
xml = w.x.query('.')
INTO #scheduler_issues
FROM #scheduler_monitor AS sm
CROSS APPLY sm.scheduler_monitor.nodes('//event') AS w(x)
- WHERE (w.x.exist('(data[@name="status"]/text[.= "WARNING"])') = @warnings_only OR @warnings_only = 0)
+ CROSS APPLY
+ (
+ SELECT
+ sql_cpu_utilization = w.x.value('(data[@name="process_utilization"]/value)[1]', 'integer'),
+ system_idle = w.x.value('(data[@name="system_idle"]/value)[1]', 'integer'),
+ memory_utilization = w.x.value('(data[@name="memory_utilization"]/value)[1]', 'integer'),
+ page_faults = w.x.value('(data[@name="page_faults"]/value)[1]', 'bigint'),
+ working_set_delta_mb =
+ CONVERT
+ (
+ decimal(19, 2),
+ w.x.value('(data[@name="working_set_delta"]/value)[1]', 'bigint') / 1048576.
+ )
+ ) AS v
+ WHERE
+ (
+ @warnings_only = 0
+ OR v.sql_cpu_utilization >= 90
+ OR (100 - v.sql_cpu_utilization - v.system_idle) >= 50
+ OR v.memory_utilization <= 50
+ )
OPTION(RECOMPILE, MAXDOP 1);
IF @debug = 1
@@ -4589,17 +4756,15 @@ AND ca.utc_timestamp < @end_date';
' + CASE
WHEN @log_to_table = 1
THEN N''
- ELSE N'finding = ''scheduler monitor issues'','
+ ELSE N'finding = ''scheduler monitor health'','
END +
N'
si.event_time,
- si.scheduler_id,
- si.cpu_id,
- si.status,
- si.is_online,
- si.is_runnable,
- si.is_running,
- non_yielding_time_ms =
+ si.sql_cpu_utilization,
+ si.other_process_cpu,
+ si.system_idle,
+ si.memory_utilization,
+ page_faults =
REPLACE
(
CONVERT
@@ -4608,29 +4773,14 @@ AND ca.utc_timestamp < @end_date';
CONVERT
(
money,
- si.non_yielding_time_ms
+ si.page_faults
),
1
),
N''.00'',
N''''
),
- thread_quantum_ms =
- REPLACE
- (
- CONVERT
- (
- nvarchar(30),
- CONVERT
- (
- money,
- si.thread_quantum_ms
- ),
- 1
- ),
- N''.00'',
- N''''
- )
+ si.working_set_delta_mb
FROM #scheduler_issues AS si';
/* Add the WHERE clause only for table logging */
@@ -4642,7 +4792,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_scheduler_issues, N'''', N'''''')
+ ),
'{table_check}',
@log_table_scheduler_issues
),
@@ -4679,14 +4834,12 @@ AND ca.utc_timestamp < @end_date';
' + @log_table_scheduler_issues + N'
(
event_time,
- scheduler_id,
- cpu_id,
- status,
- is_online,
- is_runnable,
- is_running,
- non_yielding_time_ms,
- thread_quantum_ms
+ sql_cpu_utilization,
+ other_process_cpu,
+ system_idle,
+ memory_utilization,
+ page_faults,
+ working_set_delta_mb
)' +
@dsql;
@@ -4750,8 +4903,15 @@ AND ca.utc_timestamp < @end_date';
severity = w.x.value('(data[@name="severity"]/value)[1]', 'integer'),
state = w.x.value('(data[@name="state"]/value)[1]', 'integer'),
message = w.x.value('(data[@name="message"]/value)[1]', 'nvarchar(max)'),
- database_name = DB_NAME(w.x.value('(data[@name="database_id"]/value)[1]', 'integer')),
- database_id = w.x.value('(data[@name="database_id"]/value)[1]', 'integer'),
+ /*
+ database_id rides on error_reported as an ACTION, not a
+ data element — reading the data axis returns NULL for every
+ event, which blanked these two columns for all severe
+ errors. session_id further up reads the action axis the
+ same way.
+ */
+ database_name = DB_NAME(w.x.value('(action[@name="database_id"]/value)[1]', 'integer')),
+ database_id = w.x.value('(action[@name="database_id"]/value)[1]', 'integer'),
xml = w.x.query('.')
INTO #error_info
FROM #error_reported AS er
@@ -4837,7 +4997,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_severe_errors, N'''', N'''''')
+ ),
'{table_check}',
@log_table_severe_errors
),
@@ -5226,39 +5391,53 @@ AND ca.utc_timestamp < @end_date';
THEN
(
SELECT
+ /*
+ ISNULL because either metadata function
+ returns NULL when the database or object
+ no longer resolves (dropped, offline,
+ detached since the event fired) — and a
+ NULL here nulls the whole concatenation,
+ rendering the query column blank. The
+ raw text still carries the database and
+ object ids, which beats an empty cell.
+ */
[processing-instruction(query)] =
- OBJECT_SCHEMA_NAME
+ ISNULL
(
- SUBSTRING
- (
- kheb.query_text,
- CHARINDEX(N'Object Id = ', kheb.query_text) + 12,
- LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12)
- )
- ,
- SUBSTRING
- (
- kheb.query_text,
- CHARINDEX(N'Database Id = ', kheb.query_text) + 14,
- CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14)
- )
- ) +
- N'.' +
- OBJECT_NAME
- (
- SUBSTRING
- (
- kheb.query_text,
- CHARINDEX(N'Object Id = ', kheb.query_text) + 12,
- LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12)
- )
- ,
- SUBSTRING
- (
- kheb.query_text,
- CHARINDEX(N'Database Id = ', kheb.query_text) + 14,
- CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14)
- )
+ OBJECT_SCHEMA_NAME
+ (
+ SUBSTRING
+ (
+ kheb.query_text,
+ CHARINDEX(N'Object Id = ', kheb.query_text) + 12,
+ LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12)
+ )
+ ,
+ SUBSTRING
+ (
+ kheb.query_text,
+ CHARINDEX(N'Database Id = ', kheb.query_text) + 14,
+ CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14)
+ )
+ ) +
+ N'.' +
+ OBJECT_NAME
+ (
+ SUBSTRING
+ (
+ kheb.query_text,
+ CHARINDEX(N'Object Id = ', kheb.query_text) + 12,
+ LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12)
+ )
+ ,
+ SUBSTRING
+ (
+ kheb.query_text,
+ CHARINDEX(N'Database Id = ', kheb.query_text) + 14,
+ CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14)
+ )
+ ),
+ kheb.query_text
)
FOR XML
PATH(N''),
@@ -5438,7 +5617,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_blocking, N'''', N'''''')
+ ),
'{table_check}',
@log_table_blocking
),
@@ -5543,21 +5727,29 @@ AND ca.utc_timestamp < @end_date';
END;
ELSE
BEGIN
- SELECT
- finding = CASE
- WHEN @what_to_check NOT IN ('all', 'locking')
- THEN 'blocking skipped, @what_to_check set to ' + @what_to_check
- WHEN @skip_locks = 1
- THEN 'blocking skipped, @skip_locks set to 1'
- WHEN @what_to_check IN ('all', 'locking')
- THEN 'no blocking found between ' +
- RTRIM(CONVERT(date, @start_date)) +
- ' and ' +
- RTRIM(CONVERT(date, @end_date)) +
- ' with @warnings_only set to ' +
- RTRIM(@warnings_only)
- ELSE 'no blocking found!'
- END;
+ /*
+ No-results messages are client output, and @log_to_table
+ documents itself as log-instead-of-return — same guard the
+ populated path above already uses.
+ */
+ IF @log_to_table = 0
+ BEGIN
+ SELECT
+ finding = CASE
+ WHEN @what_to_check NOT IN ('all', 'locking')
+ THEN 'blocking skipped, @what_to_check set to ' + @what_to_check
+ WHEN @skip_locks = 1
+ THEN 'blocking skipped, @skip_locks set to 1'
+ WHEN @what_to_check IN ('all', 'locking')
+ THEN 'no blocking found between ' +
+ RTRIM(CONVERT(date, @start_date)) +
+ ' and ' +
+ RTRIM(CONVERT(date, @end_date)) +
+ ' with @warnings_only set to ' +
+ RTRIM(@warnings_only)
+ ELSE 'no blocking found!'
+ END;
+ END;
END;
/*Grab available plans from the cache*/
@@ -5825,39 +6017,51 @@ AND ca.utc_timestamp < @end_date';
THEN
(
SELECT
+ /*
+ Same ISNULL guard as the blocking
+ twin above: a dropped or offline
+ database/object NULLs the metadata
+ functions, and the concatenation
+ with them — falling back to the
+ raw text keeps the ids visible.
+ */
[processing-instruction(query)] =
- OBJECT_SCHEMA_NAME
- (
- SUBSTRING
- (
- dp.query_text,
- CHARINDEX(N''Object Id = '', dp.query_text) + 12,
- LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12)
- )
- ,
- SUBSTRING
- (
- dp.query_text,
- CHARINDEX(N''Database Id = '', dp.query_text) + 14,
- CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14)
- )
- ) +
- N''.'' +
- OBJECT_NAME
+ ISNULL
(
- SUBSTRING
- (
- dp.query_text,
- CHARINDEX(N''Object Id = '', dp.query_text) + 12,
- LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12)
- )
- ,
- SUBSTRING
- (
- dp.query_text,
- CHARINDEX(N''Database Id = '', dp.query_text) + 14,
- CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14)
- )
+ OBJECT_SCHEMA_NAME
+ (
+ SUBSTRING
+ (
+ dp.query_text,
+ CHARINDEX(N''Object Id = '', dp.query_text) + 12,
+ LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12)
+ )
+ ,
+ SUBSTRING
+ (
+ dp.query_text,
+ CHARINDEX(N''Database Id = '', dp.query_text) + 14,
+ CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14)
+ )
+ ) +
+ N''.'' +
+ OBJECT_NAME
+ (
+ SUBSTRING
+ (
+ dp.query_text,
+ CHARINDEX(N''Object Id = '', dp.query_text) + 12,
+ LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12)
+ )
+ ,
+ SUBSTRING
+ (
+ dp.query_text,
+ CHARINDEX(N''Database Id = '', dp.query_text) + 14,
+ CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14)
+ )
+ ),
+ dp.query_text
)
FOR XML
PATH(N''''),
@@ -5902,7 +6106,12 @@ AND ca.utc_timestamp < @end_date';
(
REPLACE
(
- @mdsql_template,
+ REPLACE
+ (
+ @mdsql_template,
+ '{table_check_literal}',
+ REPLACE(@log_table_deadlocks, N'''', N'''''')
+ ),
'{table_check}',
@log_table_deadlocks
),
@@ -5990,19 +6199,26 @@ AND ca.utc_timestamp < @end_date';
END;
ELSE
BEGIN
- SELECT
- finding = CASE
- WHEN @what_to_check NOT IN ('all', 'locking')
- THEN 'deadlocks skipped, @what_to_check set to ' + @what_to_check
- WHEN @skip_locks = 1
- THEN 'deadlocks skipped, @skip_locks set to 1'
- WHEN @what_to_check IN ('all', 'locking')
- THEN 'no deadlocks found between ' +
- RTRIM(CONVERT(date, @start_date)) +
- ' and ' +
- RTRIM(CONVERT(date, @end_date))
- ELSE 'no deadlocks found!'
- END;
+ /*
+ Client output only — same @log_to_table guard as the
+ blocking twin above.
+ */
+ IF @log_to_table = 0
+ BEGIN
+ SELECT
+ finding = CASE
+ WHEN @what_to_check NOT IN ('all', 'locking')
+ THEN 'deadlocks skipped, @what_to_check set to ' + @what_to_check
+ WHEN @skip_locks = 1
+ THEN 'deadlocks skipped, @skip_locks set to 1'
+ WHEN @what_to_check IN ('all', 'locking')
+ THEN 'no deadlocks found between ' +
+ RTRIM(CONVERT(date, @start_date)) +
+ ' and ' +
+ RTRIM(CONVERT(date, @end_date))
+ ELSE 'no deadlocks found!'
+ END;
+ END;
END;
IF @debug = 1
@@ -6209,65 +6425,74 @@ AND ca.utc_timestamp < @end_date';
ap.avg_worker_time_ms DESC
OPTION(RECOMPILE, MAXDOP 1);
- IF EXISTS
- (
- SELECT
- 1/0
- FROM #all_available_plans AS ap
- WHERE ap.finding = 'available plans for blocking'
- )
- BEGIN
- SELECT
- aap.*
- FROM #all_available_plans AS aap
- WHERE aap.finding = 'available plans for blocking'
- ORDER BY
- aap.avg_worker_time_ms DESC
- OPTION(RECOMPILE);
- END;
- ELSE
+ /*
+ The available-plans grids and their no-plans messages are client
+ output with no logging counterpart, so the documented
+ log-instead-of-return contract suppresses the lot when
+ @log_to_table = 1.
+ */
+ IF @log_to_table = 0
BEGIN
- /* Only show this message if we found blocking but no plans */
IF EXISTS
(
SELECT
1/0
- FROM #blocks AS b
+ FROM #all_available_plans AS ap
+ WHERE ap.finding = 'available plans for blocking'
)
BEGIN
SELECT
- finding = 'no cached plans found for blocking queries';
+ aap.*
+ FROM #all_available_plans AS aap
+ WHERE aap.finding = 'available plans for blocking'
+ ORDER BY
+ aap.avg_worker_time_ms DESC
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ /* Only show this message if we found blocking but no plans */
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #blocks AS b
+ )
+ BEGIN
+ SELECT
+ finding = 'no cached plans found for blocking queries';
+ END;
END;
- END;
- IF EXISTS
- (
- SELECT
- 1/0
- FROM #all_available_plans AS ap
- WHERE ap.finding = 'available plans for deadlocks'
- )
- BEGIN
- SELECT
- aap.*
- FROM #all_available_plans AS aap
- WHERE aap.finding = 'available plans for deadlocks'
- ORDER BY
- aap.avg_worker_time_ms DESC
- OPTION(RECOMPILE);
- END;
- ELSE
- BEGIN
- /* Only show this message if we found deadlocks but no plans */
IF EXISTS
(
SELECT
1/0
- FROM #deadlocks_parsed AS dp
+ FROM #all_available_plans AS ap
+ WHERE ap.finding = 'available plans for deadlocks'
)
BEGIN
SELECT
- finding = 'no cached plans found for deadlock queries';
+ aap.*
+ FROM #all_available_plans AS aap
+ WHERE aap.finding = 'available plans for deadlocks'
+ ORDER BY
+ aap.avg_worker_time_ms DESC
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ /* Only show this message if we found deadlocks but no plans */
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #deadlocks_parsed AS dp
+ )
+ BEGIN
+ SELECT
+ finding = 'no cached plans found for deadlock queries';
+ END;
END;
END;
END; /*End locks*/
From 56761ea246232b49e65c62508535ab5324459791 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 13 Jul 2026 17:38:45 -0400
Subject: [PATCH 06/60] sp_HumanEvents: fix ten verified defects across
filters, routing, output tables, and cleanup
A multi-agent bug hunt with adversarial verification and live-server
validation confirmed ten defects by reproduction; every fix here was
re-verified live, and the fixes themselves went through a second
adversarial audit that caught and corrected a regression in one of
them before it shipped.
Filters and math:
- @blocking_duration_ms over ~36 minutes overflowed int arithmetic in
the session predicate builder and killed the procedure with a
misleading arithmetic error before any session was created. The
multiplication now converts to bigint first. The query duration
filter never had this problem - its parameter is decimal(18,3), so
its arithmetic was already decimal.
- avg_ms_per_wait used bigint integer division at three sites,
truncating fractional milliseconds and under-reporting averages.
Now decimal.
- The executions count doubled whenever plans were collected
(@skip_plans = 0): both branches of the query aggregation carry a
plan_handle, and COUNT_BIG counted showplan events as executions.
An is_execution discriminator now counts only real execution events.
Harvest routing:
- All three harvest-loop routing CASEs (view naming, table creation,
insert building) matched event types as floating substrings of the
whole session name, letting @custom_name contaminate the routing: a
waits session named with @custom_name = 'deadlock' could have its
view pointed at a waits-shaped table, and the resulting CREATE VIEW
failure aborted the entire harvest for every session. All patterns
are now anchored to the event-type segment of the session name
(keeper_HumanEvents_...), which also closes the original
reported gap: lock/locks/locking aliases never got the blocking
view, because one CASE matched %block% where everything else
matched %lock%.
Output tables:
- The _parameterization suffix was appended OUTSIDE the QUOTENAME
brackets in both the CREATE TABLE and INSERT builders, producing an
invalid object name that broke ALL compiles-to-table logging on SQL
Server 2017+ with a syntax error. The suffix now joins the table
name before quoting.
- The create-table loop re-executed the previous iteration's CREATE
TABLE when a later table already existed, aborting the harvest with
"There is already an object". The execute is now guarded and the
variable reset.
- A pure collector run (@output_database_name set, @keep_alive = 0)
created and started a throwaway session the harvest loop never
reads - it only polls keeper sessions - leaving it running uselessly
until a later invocation's startup cleanup reaped it. Collector runs
no longer create a session; @keep_alive = 1 with an output database
still does, since that is the single-call create-and-collect path.
Cleanup and files:
- @cleanup dropped ANY table or view whose name merely contained
'HumanEvents', in every schema of the output database - silently
destroying user objects. Patterns are now anchored to the tool's
own names and scoped to @output_schema_name. The accumulator also
used SCHEMA_NAME(), which runs in the calling database's context
and NULL-poisoned the whole DROP list for cross-database cleanup,
silently cleaning nothing; it now joins the output database's
sys.schemas.
- Throwaway event_file runs leaked one .xel file into the LOG
directory per run, forever: DROP EVENT SESSION removes only
metadata, and the per-run GUID filename means max_rollover_files
never prunes across runs. After the target is read and dropped, the
files are now deleted via sys.xp_delete_files where it exists
(present on 2017+, documented on 2022+; checked by existence, not
version), with an honest message on versions that cannot delete
files from T-SQL.
Verified: installs and runs clean on SQL Server 2016 through 2025
across event types, targets, and output-table mode; a decoy user
table and view containing 'HumanEvents' in their names survive
@cleanup while tool objects drop; the poison-named waits keeper
scenario that aborted the harvest now logs rows and creates the
correct views; compiles-to-table logging works end to end including
the parameterization table; event_file runs leave zero files behind
on servers that can delete them.
Co-Authored-By: Claude Fable 5
---
sp_HumanEvents/sp_HumanEvents.sql | 285 +++++++++++++++++++++++++-----
1 file changed, 245 insertions(+), 40 deletions(-)
diff --git a/sp_HumanEvents/sp_HumanEvents.sql b/sp_HumanEvents/sp_HumanEvents.sql
index 44f7d8dc..e595d741 100644
--- a/sp_HumanEvents/sp_HumanEvents.sql
+++ b/sp_HumanEvents/sp_HumanEvents.sql
@@ -517,6 +517,15 @@ DECLARE
@max_id integer,
@event_type_check sysname,
@object_name_check nvarchar(1000) = N'',
+ /*
+ The parameterization table's fully-quoted name. Built separately
+ because the _parameterization suffix has to land INSIDE the leaf
+ QUOTENAME — appending it after @object_name_check produced
+ [db].[schema].[table]_parameterization, which is not a valid name,
+ and broke every compiles-to-table run on SQL Server 2017+ with a
+ syntax error.
+ */
+ @param_table_check nvarchar(1000) = N'',
@table_sql nvarchar(max) = N'',
@view_tracker bit,
@spe nvarchar(max) = N'.sys.sp_executesql ',
@@ -1304,7 +1313,15 @@ END;
IF @blocking_duration_ms > 0
BEGIN
- SET @blocking_duration_ms_filter += N' AND duration >= ' + CONVERT(nvarchar(20), (@blocking_duration_ms * 1000)) + @nc10;
+ /*
+ The bigint CONVERT must happen BEFORE the multiplication:
+ @blocking_duration_ms is an integer, and int * 1000 is evaluated in
+ int arithmetic, so anything over 2,147,483 ms (~36 minutes)
+ overflowed and killed the procedure with an arithmetic error. The
+ query duration filter above never had this problem — its parameter
+ is decimal(18,3), so its multiplication is decimal arithmetic.
+ */
+ SET @blocking_duration_ms_filter += N' AND duration >= ' + CONVERT(nvarchar(20), CONVERT(bigint, @blocking_duration_ms) * 1000) + @nc10;
END;
IF @wait_duration_ms > 0
@@ -1726,12 +1743,35 @@ SET @session_sql +=
/* This creates the event session */
SET @session_sql += @session_with;
-IF @debug = 1 BEGIN RAISERROR(@session_sql, 0, 1) WITH NOWAIT; END;
-EXECUTE (@session_sql);
+/*
+A pure collector run — output database and schema set, @keep_alive = 0,
+not cleaning up — GOTOs straight to the harvesting loop, which only
+reads keeper_HumanEvents_ sessions. The throwaway GUID session this
+would create is never read by that loop and never dropped by this
+invocation (the teardown sits above the GOTO label), so it just ran
+uselessly until a later invocation's startup cleanup reaped it.
+@keep_alive = 1 with an output database still creates its session here:
+that is the single-call create-and-collect path.
+*/
+IF
+(
+ @output_database_name <> N''
+ AND @output_schema_name <> N''
+ AND @cleanup = 0
+ AND @keep_alive = 0
+)
+BEGIN
+ IF @debug = 1 BEGIN RAISERROR(N'Collector run: skipping throwaway session creation', 0, 1) WITH NOWAIT; END;
+END;
+ELSE
+BEGIN
+ IF @debug = 1 BEGIN RAISERROR(@session_sql, 0, 1) WITH NOWAIT; END;
+ EXECUTE (@session_sql);
-/* This starts the event session */
-IF @debug = 1 BEGIN RAISERROR(@start_sql, 0, 1) WITH NOWAIT; END;
-EXECUTE (@start_sql);
+ /* This starts the event session */
+ IF @debug = 1 BEGIN RAISERROR(@start_sql, 0, 1) WITH NOWAIT; END;
+ EXECUTE (@start_sql);
+END;
/* bail out here if we want to keep the session and not log to tables*/
IF @keep_alive = 1
@@ -2013,6 +2053,13 @@ BEGIN
q.query_plan_hash_signed,
q.query_hash_signed,
plan_handle = q.plan_handle,
+ /*
+ Only this branch holds real execution events; the showplan
+ branch below contributes memory numbers for the same hash
+ group. The executions count has to see the difference, or
+ collecting plans doubles it.
+ */
+ is_execution = 1,
/*totals*/
total_cpu_ms = ISNULL(q.cpu_ms, 0.),
total_logical_reads = ISNULL(q.logical_reads, 0.),
@@ -2042,6 +2089,7 @@ BEGIN
q.query_plan_hash_signed,
q.query_hash_signed,
q.plan_handle,
+ is_execution = 0,
/*totals*/
total_cpu_ms = NULL,
total_logical_reads = NULL,
@@ -2087,7 +2135,24 @@ BEGIN
avg_used_memory_mb = AVG(qa.avg_used_memory_mb),
avg_granted_memory_mb = AVG(qa.avg_granted_memory_mb),
avg_rows = AVG(qa.avg_rows),
- executions = COUNT_BIG(qa.plan_handle)
+ /*
+ Counts only rows from the execution branch, exactly as
+ COUNT_BIG(plan_handle) did before plan collection added the
+ showplan branch — which doubled this number whenever
+ @skip_plans = 0, because both branches carry a plan_handle.
+ Written as SUM over 1s and 0s so no NULL ever reaches the
+ aggregate.
+ */
+ executions =
+ SUM
+ (
+ CASE
+ WHEN qa.is_execution = 1
+ AND qa.plan_handle IS NOT NULL
+ THEN 1
+ ELSE 0
+ END
+ )
INTO #totals
FROM query_agg AS qa
GROUP BY
@@ -2834,7 +2899,8 @@ BEGIN
total_waits = COUNT_BIG(*),
sum_duration_ms = SUM(wa.duration_ms),
sum_signal_duration_ms = SUM(wa.signal_duration_ms),
- avg_ms_per_wait = SUM(wa.duration_ms) / COUNT_BIG(*)
+ /* * 1.0 forces decimal division; bigint / bigint truncates */
+ avg_ms_per_wait = CONVERT(decimal(38,2), SUM(wa.duration_ms) * 1.0 / COUNT_BIG(*))
FROM #waits_agg AS wa
GROUP BY
wa.wait_type
@@ -2850,7 +2916,8 @@ BEGIN
total_waits = COUNT_BIG(*),
sum_duration_ms = SUM(wa.duration_ms),
sum_signal_duration_ms = SUM(wa.signal_duration_ms),
- avg_ms_per_wait = SUM(wa.duration_ms) / COUNT_BIG(*)
+ /* * 1.0 forces decimal division; bigint / bigint truncates */
+ avg_ms_per_wait = CONVERT(decimal(38,2), SUM(wa.duration_ms) * 1.0 / COUNT_BIG(*))
FROM #waits_agg AS wa
GROUP BY
wa.database_name,
@@ -2877,8 +2944,9 @@ BEGIN
SUM(wa.duration_ms),
sum_signal_duration_ms =
SUM(wa.signal_duration_ms),
+ /* * 1.0 forces decimal division; bigint / bigint truncates */
avg_ms_per_wait =
- SUM(wa.duration_ms) / COUNT_BIG(*)
+ CONVERT(decimal(38,2), SUM(wa.duration_ms) * 1.0 / COUNT_BIG(*))
FROM #waits_agg AS wa
GROUP BY
wa.database_name,
@@ -3655,6 +3723,57 @@ BEGIN
RAISERROR(N'and dropping session', 0, 1) WITH NOWAIT;
END;
EXECUTE (@drop_sql);
+
+ /*
+ DROP EVENT SESSION removes only metadata — the event_file target's
+ .xel files stay on disk, and because every throwaway run uses a
+ fresh GUID in the filename, max_rollover_files never prunes across
+ runs: one orphaned file per run, forever. The file has served its
+ whole purpose by now (it was read into the temp tables above), so
+ delete it where we can. sys.xp_delete_files is checked by existence
+ rather than version on purpose — it is present on 2017 and 2019 as
+ well as 2022+ (verified live), just not documented until 2022 — and
+ it wants one absolute path pattern. Where it does not exist, say
+ where the file was left rather than leaving it silently.
+ */
+ IF @azure = 0
+ AND LOWER(@target_output) = N'event_file'
+ BEGIN
+ DECLARE
+ @xel_pattern nvarchar(4000) =
+ LEFT
+ (
+ CONVERT(nvarchar(4000), SERVERPROPERTY('ErrorLogFileName')),
+ LEN(CONVERT(nvarchar(4000), SERVERPROPERTY('ErrorLogFileName'))) -
+ CHARINDEX(N'\', REVERSE(CONVERT(nvarchar(4000), SERVERPROPERTY('ErrorLogFileName'))))
+ ) +
+ N'\' +
+ @session_name +
+ N'*.xel';
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.all_objects AS ao
+ WHERE ao.name = N'xp_delete_files'
+ )
+ BEGIN
+ BEGIN TRY
+ EXECUTE sys.xp_delete_files
+ @xel_pattern;
+
+ IF @debug = 1 BEGIN RAISERROR(N'Deleted session files: %s', 0, 1, @xel_pattern) WITH NOWAIT; END;
+ END TRY
+ BEGIN CATCH
+ RAISERROR(N'Could not delete session files matching %s; clean them up manually.', 10, 1, @xel_pattern) WITH NOWAIT;
+ END CATCH;
+ END;
+ ELSE
+ BEGIN
+ RAISERROR(N'This SQL Server version cannot delete files from T-SQL; session files matching %s were left behind.', 10, 1, @xel_pattern) WITH NOWAIT;
+ END;
+ END;
END;
RETURN;
@@ -3852,16 +3971,35 @@ BEGIN
SET
hew.event_type_short =
CASE
- WHEN hew.event_type LIKE N'%block%'
+ /*
+ Every pattern here is anchored to the event-type
+ segment of the session name —
+ keeper_HumanEvents_[_] —
+ never floated over the whole string. A floating
+ match lets @custom_name contaminate the routing: a
+ waits session named with @custom_name = 'deadlock'
+ contains 'lock', and a floating %lock% classified
+ it as Blocking while the table-creation logic
+ (correctly) built it a waits table, so the Blocking
+ view was pointed at a waits table and the CREATE
+ VIEW error killed the entire harvest.
+
+ Anchored, the aliases still all land: block, blocks,
+ blocking, lock, locks, and locking are covered by
+ the two blocking prefixes, and the compiles branch
+ needs no recompile exclusion because 'recomp...'
+ cannot start with 'comp'.
+ */
+ WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]block%'
+ OR hew.event_type LIKE N'keeper[_]HumanEvents[_]lock%'
THEN N'[_]Blocking'
- WHEN ( hew.event_type LIKE N'%comp%'
- AND hew.event_type NOT LIKE N'%re%' )
+ WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]comp%'
THEN N'[_]Compiles'
- WHEN hew.event_type LIKE N'%quer%'
+ WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]quer%'
THEN N'[_]Queries'
- WHEN hew.event_type LIKE N'%recomp%'
+ WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]recomp%'
THEN N'[_]Recompiles'
- WHEN hew.event_type LIKE N'%wait%'
+ WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]wait%'
THEN N'[_]Waits'
ELSE N'?'
END
@@ -3902,7 +4040,19 @@ BEGIN
N'.' +
QUOTENAME(hew.output_schema) +
N'.' +
- QUOTENAME(hew.output_table)
+ QUOTENAME(hew.output_table),
+ /*
+ REPLACE on the RAW table name before quoting, so the
+ companion _parameterization worker row and the base
+ compiles row both resolve to the same, valid
+ [db].[schema].[base_parameterization] name.
+ */
+ @param_table_check =
+ QUOTENAME(hew.output_database) +
+ N'.' +
+ QUOTENAME(hew.output_schema) +
+ N'.' +
+ QUOTENAME(REPLACE(hew.output_table, N'_parameterization', N'') + N'_parameterization')
FROM #human_events_worker AS hew
WHERE hew.id = @min_id
AND hew.is_table_created = 0;
@@ -3913,12 +4063,13 @@ BEGIN
SELECT
@table_sql =
CASE
- WHEN @event_type_check LIKE N'%wait%'
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]wait%'
THEN N'CREATE TABLE ' + @object_name_check + @nc10 +
N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 +
N' database_name sysname NULL, wait_type nvarchar(60) NULL, duration_ms bigint NULL, signal_duration_ms bigint NULL, ' + @nc10 +
N' wait_resource sysname NULL, query_plan_hash_signed binary(8) NULL, query_hash_signed binary(8) NULL, plan_handle varbinary(64) NULL );'
- WHEN @event_type_check LIKE N'%lock%'
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]block%'
+ OR @event_type_check LIKE N'keeper[_]HumanEvents[_]lock%'
THEN N'CREATE TABLE ' + @object_name_check + @nc10 +
N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, ' + @nc10 +
N' activity nvarchar(20) NULL, database_name sysname NULL, database_id integer NULL, object_id bigint NULL, contentious_object AS OBJECT_NAME(object_id, database_id), ' + @nc10 +
@@ -3926,7 +4077,7 @@ BEGIN
N' wait_time bigint NULL, transaction_name sysname NULL, last_transaction_started nvarchar(30) NULL, wait_resource nvarchar(100) NULL, ' + @nc10 +
N' lock_mode nvarchar(10) NULL, status nvarchar(10) NULL, priority integer NULL, transaction_count integer NULL, ' + @nc10 +
N' client_app sysname NULL, host_name sysname NULL, login_name sysname NULL, isolation_level nvarchar(30) NULL, sql_handle varbinary(64) NULL, blocked_process_report XML NULL );'
- WHEN @event_type_check LIKE N'%quer%'
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]quer%'
THEN N'CREATE TABLE ' + @object_name_check + @nc10 +
N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 +
N' database_name sysname NULL, object_name nvarchar(512) NULL, sql_text nvarchar(max) NULL, statement nvarchar(max) NULL, ' + @nc10 +
@@ -3935,12 +4086,12 @@ BEGIN
N' spills_mb decimal(18,2) NULL, row_count decimal(18,2) NULL, estimated_rows decimal(18,2) NULL, dop integer NULL, ' + @nc10 +
N' serial_ideal_memory_mb decimal(18,2) NULL, requested_memory_mb decimal(18,2) NULL, used_memory_mb decimal(18,2) NULL, ideal_memory_mb decimal(18,2) NULL, ' + @nc10 +
N' granted_memory_mb decimal(18,2) NULL, query_plan_hash_signed binary(8) NULL, query_hash_signed binary(8) NULL, plan_handle varbinary(64) NULL );'
- WHEN @event_type_check LIKE N'%recomp%'
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]recomp%'
THEN N'CREATE TABLE ' + @object_name_check + @nc10 +
N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 +
N' database_name sysname NULL, object_name nvarchar(512) NULL, recompile_cause sysname NULL, statement_text nvarchar(max) NULL, statement_text_checksum AS CHECKSUM(database_name + statement_text) PERSISTED '
+ CASE WHEN @compile_events = 1 THEN N', compile_cpu_ms bigint NULL, compile_duration_ms bigint NULL );' ELSE N' );' END
- WHEN @event_type_check LIKE N'%comp%' AND @event_type_check NOT LIKE N'%re%'
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]comp%'
THEN N'CREATE TABLE ' + @object_name_check + @nc10 +
N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 +
N' database_name sysname NULL, object_name nvarchar(512) NULL, statement_text nvarchar(max) NULL, statement_text_checksum AS CHECKSUM(database_name + statement_text) PERSISTED '
@@ -3948,7 +4099,7 @@ BEGIN
+ CASE WHEN @parameterization_events = 1
THEN
@nc10 +
- N'CREATE TABLE ' + @object_name_check + N'_parameterization' + @nc10 +
+ N'CREATE TABLE ' + @param_table_check + @nc10 +
N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 +
N' database_name sysname NULL, sql_text nvarchar(max) NULL, compile_cpu_time_ms bigint NULL, compile_duration_ms bigint NULL, query_param_type integer NULL, ' + @nc10 +
N' is_cached bit NULL, is_recompiled bit NULL, compile_code sysname NULL, has_literals bit NULL, is_parameterizable bit NULL, parameterized_values_count bigint NULL, ' + @nc10 +
@@ -3959,9 +4110,21 @@ BEGIN
END;
END;
- IF @debug = 1 BEGIN RAISERROR(@table_sql, 0, 1) WITH NOWAIT; END;
- EXECUTE sys.sp_executesql
- @table_sql;
+ /*
+ Guarded because @table_sql is only assigned inside the
+ IF OBJECT_ID(...) IS NULL block above. When a later worker
+ row's table already exists, the variable still holds the
+ PREVIOUS iteration's CREATE TABLE, and re-executing it threw
+ "There is already an object named..." and killed the loop.
+ */
+ IF @table_sql <> N''
+ BEGIN
+ IF @debug = 1 BEGIN RAISERROR(@table_sql, 0, 1) WITH NOWAIT; END;
+ EXECUTE sys.sp_executesql
+ @table_sql;
+
+ SET @table_sql = N'';
+ END;
IF @debug = 1 BEGIN RAISERROR(N'Updating #human_events_worker to set is_table_created for %s', 0, 1, @event_type_check) WITH NOWAIT; END;
UPDATE
@@ -4265,6 +4428,13 @@ END;
QUOTENAME(hew.output_schema) +
N'.' +
QUOTENAME(hew.output_table),
+ /* Same construction as the create-table loop above. */
+ @param_table_check =
+ QUOTENAME(hew.output_database) +
+ N'.' +
+ QUOTENAME(hew.output_schema) +
+ N'.' +
+ QUOTENAME(REPLACE(hew.output_table, N'_parameterization', N'') + N'_parameterization'),
@date_filter =
DATEADD
(
@@ -4289,7 +4459,7 @@ END;
(
nvarchar(max),
CASE
- WHEN @event_type_check LIKE N'%wait%' /*Wait stats!*/
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]wait%' /*Wait stats!*/
THEN CONVERT
(
nvarchar(max),
@@ -4345,7 +4515,8 @@ WHERE (c.exist(''(data[@name="duration"]/value/text()[. > 0])'') = 1
OR @gimme_danger = 1)
AND c.exist(''@timestamp[. > sql:variable("@date_filter")]'') = 1;')
)
- WHEN @event_type_check LIKE N'%lock%' /*Blocking!*/
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]block%'
+ OR @event_type_check LIKE N'keeper[_]HumanEvents[_]lock%' /*Blocking!*/
/*To cut down on nonsense, I'm only inserting new blocking scenarios*/
/*Any existing blocking scenarios will update the blocking duration*/
THEN CONVERT
@@ -4525,7 +4696,7 @@ JOIN
AND x.hostname = x2.host_name
AND x.loginname = x2.login_name;
' ))
- WHEN @event_type_check LIKE N'%quer%' /*Queries!*/
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]quer%' /*Queries!*/
THEN
CONVERT
(
@@ -4588,7 +4759,7 @@ OUTER APPLY xet.human_events_xml.nodes(''//event'') AS oa(c)
WHERE oa.c.exist(''@timestamp[. > sql:variable("@date_filter")]'') = 1
AND oa.c.exist(''(action[@name="query_hash_signed"]/value[. != 0])'') = 1; '
))
- WHEN @event_type_check LIKE N'%recomp%' /*Recompiles!*/
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]recomp%' /*Recompiles!*/
THEN
CONVERT
(
@@ -4635,7 +4806,7 @@ AND oa.c.exist(''@timestamp[. > sql:variable("@date_filter")]'') = 1
ORDER BY
event_time;'
)))
- WHEN @event_type_check LIKE N'%comp%' AND @event_type_check NOT LIKE N'%re%' /*Compiles!*/
+ WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]comp%' /*Compiles!*/
THEN
CONVERT
(
@@ -4689,7 +4860,7 @@ ORDER BY
CONVERT
(
nvarchar(max),
- N'INSERT INTO ' + REPLACE(@object_name_check, N'_parameterization', N'') + N'_parameterization' + N' WITH(TABLOCK) ' + @nc10 +
+ N'INSERT INTO ' + @param_table_check + N' WITH(TABLOCK) ' + @nc10 +
N'( server_name, event_time, event_type, database_name, sql_text, compile_cpu_time_ms, ' + @nc10 +
N' compile_duration_ms, query_param_type, is_cached, is_recompiled, compile_code, has_literals, ' + @nc10 +
N' is_parameterizable, parameterized_values_count, query_plan_hash, query_hash, plan_handle, statement_sql_hash ) ' + @nc10 +
@@ -5031,23 +5202,47 @@ BEGIN
/*Clean up tables*/
IF @debug = 1 BEGIN RAISERROR(N'CLEAN UP PARTY TONIGHT', 0, 1) WITH NOWAIT; END;
+ /*
+ Three deliberate things here, all learned the hard way:
+
+ The name pattern is anchored and underscore-escaped. The old
+ unanchored %HumanEvents% happily dropped any user table whose name
+ merely contained the string, in every schema of the output database.
+ The tool's own tables are all named keeper_HumanEvents_..., so that
+ is all this may match.
+
+ The schema is resolved by joining the OUTPUT database's sys.schemas,
+ not with SCHEMA_NAME(), which runs in the calling database's context
+ — for a cross-database schema_id it returns NULL, and one NULL turns
+ the whole += accumulation NULL, silently cleaning up nothing.
+
+ And it only touches @output_schema_name, where the tool created its
+ objects in the first place.
+ */
+ SET @drop_holder = N'';
+
SELECT
@cleanup_tables += N'
SELECT
@i_cleanup_tables +=
N''DROP TABLE '' +
- QUOTENAME(SCHEMA_NAME(s.schema_id)) +
+ QUOTENAME(sch.name) +
N''.'' +
QUOTENAME(s.name) +
''; '' +
NCHAR(10)
FROM ' + QUOTENAME(@output_database_name) + N'.sys.tables AS s
- WHERE s.name LIKE ''' + '%HumanEvents%' + N''';';
+ JOIN ' + QUOTENAME(@output_database_name) + N'.sys.schemas AS sch
+ ON sch.schema_id = s.schema_id
+ WHERE s.name LIKE N''keeper[_]HumanEvents[_]%''
+ AND sch.name = @sch;';
EXECUTE sys.sp_executesql
@cleanup_tables,
- N'@i_cleanup_tables nvarchar(max) OUTPUT',
- @i_cleanup_tables = @drop_holder OUTPUT;
+ N'@i_cleanup_tables nvarchar(max) OUTPUT,
+ @sch sysname',
+ @i_cleanup_tables = @drop_holder OUTPUT,
+ @sch = @output_schema_name;
IF @debug = 1
BEGIN
@@ -5062,23 +5257,33 @@ BEGIN
SET @drop_holder = N'';
+ /*
+ Same anchoring, schema join, and schema scope as the table cleanup.
+ The tool's views are named HumanEvents_... (no keeper prefix), so
+ the anchor differs from the table pattern on purpose.
+ */
SELECT
@cleanup_views += N'
SELECT
@i_cleanup_views +=
N''DROP VIEW '' +
- QUOTENAME(SCHEMA_NAME(v.schema_id)) +
+ QUOTENAME(sch.name) +
N''.'' +
QUOTENAME(v.name) +
''; '' +
NCHAR(10)
FROM ' + QUOTENAME(@output_database_name) + N'.sys.views AS v
- WHERE v.name LIKE ''' + '%HumanEvents%' + N''';';
+ JOIN ' + QUOTENAME(@output_database_name) + N'.sys.schemas AS sch
+ ON sch.schema_id = v.schema_id
+ WHERE v.name LIKE N''HumanEvents[_]%''
+ AND sch.name = @sch;';
EXECUTE sys.sp_executesql
@cleanup_views,
- N'@i_cleanup_views nvarchar(max) OUTPUT',
- @i_cleanup_views = @drop_holder OUTPUT;
+ N'@i_cleanup_views nvarchar(max) OUTPUT,
+ @sch sysname',
+ @i_cleanup_views = @drop_holder OUTPUT,
+ @sch = @output_schema_name;
IF @debug = 1
BEGIN
From ae881f6436189f8c427db14f817a2994a7e8a431 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 13 Jul 2026 21:43:30 -0400
Subject: [PATCH 07/60] sp_HumanEventsBlockViewer: fix eighteen verified
defects across sources, rollups, and logging
A multi-agent bug hunt with two-skeptic adversarial verification
reproduced sixteen defects live; two more came from a completeness
critic and were verified by hand. The fixes passed a second
adversarial audit, which also caught a routing mismatch in one of
them before it shipped.
Table-source mode:
- The @max_blocking_events cap had no ORDER BY, so reading logged
blocked process reports back kept the OLDEST N events in scan order
and silently discarded the newest - while the summary text claimed
"limited to most recent". The extraction now orders by event
timestamp descending before the cap, matching the ring buffer and
event file paths.
- The schema/table/column/timestamp validation ran as dynamic SQL
whose RAISERROR + RETURN only exited the dynamic batch - the
procedure kept going with rejected parameters, cascading raw engine
errors or returning results after the rejection message. The batch
now reports through an OUTPUT flag the procedure checks. A return
code cannot work there: RETURN with a value is illegal in an
sp_executesql batch.
- The blocked-process-threshold config gates refused table-source
runs over a server setting that has no bearing on reading captured
XML from a table. The gates now exempt calls that will actually
route to table mode - the exemption mirrors the routing condition
exactly, so a partial table-parameter call that falls back to an
XE session still hits the gate.
Rollup correctness:
- The Top Lead Blocker finding filtered on an object name re-derived
from the event-level object_id, which is unreliable for KEY, RID,
and PAGE locks - so drilling into the exact object the tool itself
reported made the root-cause finding vanish. It now filters on the
same wait_resource-decoded object every other check uses.
- One physical contention was double-counted in the per-object
rollups: the victim row resolved to the real object while its
paired lead-blocker row fell to the Unresolved sentinel. Resolved
objects now propagate onto their paired unresolved rows.
- Total lock wait time could report a blocker's own unrelated wait
(a WAITFOR, an I/O) as lock wait, because both sides of a report
share the event's transaction_id. The rollups now aggregate victim
rows only.
- The Top Lead Blocker percentage divided by a windowed total
computed after the HAVING filter, overstating every share. The
denominator is now the true pre-HAVING grand total.
Decoding and output:
- One deallocated page voided PAGE/RID object resolution for an
entire database: sys.dm_db_page_info raises rather than returning
NULL, so the set-based apply died on the first stale page and the
surrounding CATCH swallowed everything. Resolution now runs one
page per TRY/CATCH, so a bad page loses only itself.
- Twelve finding texts went entirely blank when DB_NAME() returned
NULL; a missing monitorLoop attribute dropped the lead-blocker
analysis; an empty capture window blanked the header finding; and
the @help introduction split into two result sets on a missing
UNION ALL. All guarded or corrected.
Logging:
- A long @log_table_name_prefix made QUOTENAME return NULL, which
turned every logging statement into a silent no-op - no table, no
rows, no error. Prefixes are now validated up front, by DATALENGTH
so trailing spaces cannot sneak past the identifier limit. The
name holder was also sysname, truncating fully-qualified names;
now nvarchar(776). An apostrophe in the logging identifiers broke
the DDL batch; literal-position embeds now double their quotes,
and the watermark probe template separates its literal and
identifier placeholders.
- The first-run dedup watermark was UTC-shifted while the event_time
column it stands in for is stored in local time, and it hardcoded a
one-day floor - so a first logging run could silently drop part or
all of the caller's requested window. The fallback now rides in as
a parameter carrying the caller's local-domain window start.
Verified: installs and runs clean on SQL Server 2016 through 2025;
a ten-event fixture with a cap of three returns the three newest
events where it returned the three oldest; a rejected table-source
call halts with zero leaked output; decoy user objects survive while
tool objects drop; the help introduction is one result set again.
Co-Authored-By: Claude Fable 5
---
sp_HumanEvents/sp_HumanEventsBlockViewer.sql | 436 ++++++++++++++-----
1 file changed, 318 insertions(+), 118 deletions(-)
diff --git a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
index 19ed3e0b..aa5ff891 100644
--- a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
+++ b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
@@ -107,7 +107,7 @@ BEGIN
SELECT 'it will also work with any other extended event session that captures blocking' UNION ALL
SELECT 'just use the @session_name parameter to point me there' UNION ALL
SELECT 'EXECUTE dbo.sp_HumanEventsBlockViewer @session_name = N''blocked_process_report'';' UNION ALL
- SELECT 'the system_health session also works, if you are okay with its lousy blocked process report'
+ SELECT 'the system_health session also works, if you are okay with its lousy blocked process report' UNION ALL
SELECT 'all scripts and documentation are available here: https://code.erikdarling.com' UNION ALL
SELECT 'from your loving sql server consultant, erik darling: https://erikdarling.com';
@@ -285,6 +285,15 @@ IF EXISTS
AND CONVERT(integer, c.value_in_use) = 0
)
AND @session_name NOT LIKE N'system%health'
+/*
+Table-source mode reads blocked process report XML somebody already
+captured into a table — the server's CURRENT threshold setting has no
+bearing on that, and this gate used to refuse the whole run over it.
+The exemption mirrors the routing condition exactly (@target_table AND
+@target_column both supplied): a partial table-parameter call falls
+back to reading an XE session, where the gate still applies.
+*/
+AND (@target_table IS NULL OR @target_column IS NULL)
BEGIN
RAISERROR(N'Unless you want to use the lousy version in system_health, the blocked process report needs to be enabled:
EXECUTE sys.sp_configure ''show advanced options'', 1;
@@ -304,6 +313,8 @@ IF EXISTS
AND CONVERT(integer, c.value_in_use) <> 5
)
AND @session_name NOT LIKE N'system%health'
+/* Same table-mode exemption as the hard gate above. */
+AND (@target_table IS NULL OR @target_column IS NULL)
BEGIN
RAISERROR(N'For best results, set up the blocked process report like this:
EXECUTE sys.sp_configure ''show advanced options'', 1;
@@ -340,8 +351,12 @@ DECLARE
),
@resolve_sql nvarchar(max),
@resolve_database_id integer,
+ @resolve_file_id integer,
+ @resolve_page_id bigint,
+ @resolve_object_id integer,
@key_resolution_dbs CURSOR,
@page_resolution_dbs CURSOR,
+ @page_resolution_pages CURSOR,
@azure_msg nchar(1),
@session_id integer,
@target_session_id integer,
@@ -353,13 +368,27 @@ DECLARE
@start_date_original datetime2 = @start_date,
@end_date_original datetime2 = @end_date,
@validation_sql nvarchar(max),
+ /*
+ Set by the table-source validation batch. A RETURN inside dynamic
+ SQL exits only that batch — the caller keeps running — so the batch
+ reports failure through this OUTPUT flag and the procedure checks
+ it right after the EXECUTE. A return code will not work here:
+ RETURN is not allowed in an sp_executesql batch.
+ */
+ @validation_failed bit = 0,
@extract_sql nvarchar(max),
- /*Log to table stuff*/
- @log_table_blocking sysname,
+ /*
+ Log to table stuff. The table-name holder carries a fully-qualified,
+ QUOTENAME'd three-part name — up to 258 characters per part plus two
+ dots — so sysname silently truncated long-but-legal names and the
+ CREATE TABLE failed on the mangled remainder.
+ */
+ @log_table_blocking nvarchar(776),
@cleanup_date datetime2(7),
@check_sql nvarchar(max) = N'',
@create_sql nvarchar(max) = N'',
@insert_sql nvarchar(max) = N'',
+ @mdsql_execute nvarchar(max) = N'',
@log_database_schema nvarchar(1024),
@max_event_time datetime2(7),
@dsql nvarchar(max) = N'',
@@ -441,30 +470,31 @@ SELECT
THEN 1
ELSE 0
END,
+ /*
+ {table_check_literal} carries the same name as {table_check}, but
+ lands inside a quoted string where an apostrophe in a legal
+ identifier is fatal unless doubled — so the literal position gets
+ the quote-doubled value at the substitution site and the FROM keeps
+ the raw bracketed name.
+
+ The @fallback_date parameter covers the first (empty-table) logging
+ run, and two things about it were wrong before: it hardcoded one
+ day back, silently discarding older events inside the caller's
+ requested window, and it was UTC-shifted while MAX(event_time) —
+ the value it stands in for — is stored in server-local time, which
+ pushed the watermark into the future for west-of-UTC servers and
+ dropped that run's events entirely. The substitution site passes
+ the caller's local-domain start date instead.
+ */
@mdsql = N'
-IF OBJECT_ID(''{table_check}'', ''U'') IS NOT NULL
+IF OBJECT_ID(''{table_check_literal}'', ''U'') IS NOT NULL
BEGIN
SELECT
@max_event_time =
ISNULL
(
MAX({date_column}),
- DATEADD
- (
- MINUTE,
- DATEDIFF
- (
- MINUTE,
- SYSDATETIME(),
- GETUTCDATE()
- ),
- DATEADD
- (
- DAY,
- -1,
- SYSDATETIME()
- )
- )
+ @fallback_date
)
FROM {table_check};
END;';
@@ -581,6 +611,7 @@ BEGIN
)
BEGIN
RAISERROR(N''The specified @target_schema %s does not exist in @database %s'', 11, 1, @schema, @database) WITH NOWAIT;
+ SET @validation_failed = 1;
RETURN;
END;
@@ -597,6 +628,7 @@ BEGIN
)
BEGIN
RAISERROR(N''The specified @target_table %s does not exist in @schema %s in database %s'', 11, 1, @table, @schema, @database) WITH NOWAIT;
+ SET @validation_failed = 1;
RETURN;
END;
@@ -616,6 +648,7 @@ BEGIN
)
BEGIN
RAISERROR(N''The specified @target_column %s does not exist in table %s.%s in database %s'', 11, 1, @column, @schema, @table, @database) WITH NOWAIT;
+ SET @validation_failed = 1;
RETURN;
END;
@@ -638,6 +671,7 @@ BEGIN
)
BEGIN
RAISERROR(N''The specified @target_column %s must be of XML data type.'', 11, 1, @column) WITH NOWAIT;
+ SET @validation_failed = 1;
RETURN;
END;
';
@@ -661,6 +695,7 @@ BEGIN
)
BEGIN
RAISERROR(N''The specified @timestamp_column %s does not exist in table %s.%s in database %s'', 11, 1, @timestamp_column, @schema, @table, @database) WITH NOWAIT;
+ SET @validation_failed = 1;
RETURN;
END;
@@ -683,6 +718,7 @@ BEGIN
)
BEGIN
RAISERROR(N''The specified @timestamp_column %s must be of datetime data type.'', 11, 1, @timestamp_column) WITH NOWAIT;
+ SET @validation_failed = 1;
RETURN;
END;';
END;
@@ -699,13 +735,20 @@ BEGIN
@schema sysname,
@table sysname,
@column sysname,
- @timestamp_column sysname
+ @timestamp_column sysname,
+ @validation_failed bit OUTPUT
',
@target_database,
@target_schema,
@target_table,
@target_column,
- @timestamp_column;
+ @timestamp_column,
+ @validation_failed OUTPUT;
+
+ IF @validation_failed = 1
+ BEGIN
+ RETURN;
+ END;
END;
/* Validate logging parameters */
@@ -723,6 +766,20 @@ BEGIN
ELSE @log_retention_days
END;
+ /*
+ QUOTENAME returns NULL for any input over 128 characters, and a NULL
+ table name turns every logging statement below into a silent no-op —
+ no table, no rows, no error. The one suffix appended to the prefix,
+ _BlockedProcessReport, is 21 characters, so the prefix gets 107.
+ */
+ /* DATALENGTH, not LEN: LEN ignores trailing spaces, which still
+ count toward the 128-character identifier limit inside QUOTENAME. */
+ IF DATALENGTH(@log_table_name_prefix) > 214
+ BEGIN
+ RAISERROR('@log_table_name_prefix is limited to 107 characters, so the table name suffix (_BlockedProcessReport) still fits in an identifier. Logging will be disabled.', 11, 1) WITH NOWAIT;
+ RETURN;
+ END;
+
/* Validate database exists */
IF NOT EXISTS
(
@@ -822,7 +879,13 @@ BEGIN
blocked_process_report_xml xml NULL
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for significant waits logging.'', 0, 1, ''' + @log_table_blocking + N''') WITH NOWAIT; END;
+ /*
+ The REPLACE doubles any single quote in the bracketed name:
+ this embed sits inside a string literal, where an apostrophe
+ in a legal identifier otherwise ends the literal early and
+ the whole batch fails to compile.
+ */
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for blocked process report logging.'', 0, 1, ''' + REPLACE(@log_table_blocking, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1874,21 +1937,35 @@ BEGIN
RAISERROR('Extracting blocked process reports from table %s.%s.%s', 0, 1, @target_database, @target_schema, @target_table) WITH NOWAIT;
END;
- /* Build dynamic SQL to extract the XML */
+ /*
+ Build dynamic SQL to extract the XML.
+
+ The ORDER BY event_timestamp DESC under the TOP is what makes
+ @max_blocking_events keep the MOST RECENT events, matching the
+ ring buffer and event file paths and the summary text. Without it,
+ TOP took whatever scan order produced — for an append-only log
+ table, the OLDEST rows — and silently hid the newest events, which
+ are the ones an investigation is usually after.
+ */
SET @extract_sql = N'
- SELECT TOP (' + CONVERT(nvarchar(20), CASE WHEN @max_blocking_events > 0 THEN @max_blocking_events ELSE 2147483647 END) + N')
- human_events_xml = e.x.query(''.'')
- FROM ' +
- QUOTENAME(@target_database) +
- N'.' +
- QUOTENAME(@target_schema) +
- N'.' +
- QUOTENAME(@target_table) +
- N' AS x
- CROSS APPLY x.' +
- QUOTENAME(@target_column) +
- N'.nodes(''/event'') AS e(x)
- WHERE e.x.exist(''@name[ .= "blocked_process_report"]'') = 1';
+ SELECT
+ me.human_events_xml
+ FROM
+ (
+ SELECT TOP (' + CONVERT(nvarchar(20), CASE WHEN @max_blocking_events > 0 THEN @max_blocking_events ELSE 2147483647 END) + N')
+ human_events_xml = e.x.query(''.''),
+ event_timestamp = e.x.value(''@timestamp'', ''datetime2'')
+ FROM ' +
+ QUOTENAME(@target_database) +
+ N'.' +
+ QUOTENAME(@target_schema) +
+ N'.' +
+ QUOTENAME(@target_table) +
+ N' AS x
+ CROSS APPLY x.' +
+ QUOTENAME(@target_column) +
+ N'.nodes(''/event'') AS e(x)
+ WHERE e.x.exist(''@name[ .= "blocked_process_report"]'') = 1';
/*
Add timestamp filtering if specified.
@@ -1903,17 +1980,20 @@ BEGIN
IF @timestamp_column IS NOT NULL
BEGIN
SET @extract_sql = @extract_sql + N'
- AND x.' + QUOTENAME(@timestamp_column) + N' >= @start_date
- AND x.' + QUOTENAME(@timestamp_column) + N' < @end_date';
+ AND x.' + QUOTENAME(@timestamp_column) + N' >= @start_date
+ AND x.' + QUOTENAME(@timestamp_column) + N' < @end_date';
END;
IF @timestamp_column IS NULL
BEGIN
SET @extract_sql = @extract_sql + N'
- AND e.x.exist(''@timestamp[. >= sql:variable("@start_date") and . < sql:variable("@end_date")]'') = 1';
+ AND e.x.exist(''@timestamp[. >= sql:variable("@start_date") and . < sql:variable("@end_date")]'') = 1';
END;
SET @extract_sql = @extract_sql + N'
+ ORDER BY
+ event_timestamp DESC
+ ) AS me
OPTION(RECOMPILE);
';
@@ -1967,7 +2047,12 @@ SELECT
object_id = c.value('(data[@name="object_id"]/value/text())[1]', 'integer'),
transaction_id = c.value('(data[@name="transaction_id"]/value/text())[1]', 'bigint'),
resource_owner_type = c.value('(data[@name="resource_owner_type"]/text)[1]', 'nvarchar(256)'),
- monitor_loop = c.value('(//@monitorLoop)[1]', 'integer'),
+ /* ISNULL: a BPR with no monitorLoop attribute otherwise NULLs this,
+ which fails the NOT NULL #session_leads inserts and turns every
+ monitor_loop equality UNKNOWN, silently dropping the Top Lead
+ Blocker rollup. Real loop counters are large positive numbers,
+ so 0 cannot collide. */
+ monitor_loop = ISNULL(c.value('(//@monitorLoop)[1]', 'integer'), 0),
blocking_spid = bg.value('(process/@spid)[1]', 'integer'),
blocking_ecid = bg.value('(process/@ecid)[1]', 'integer'),
blocked_spid = bd.value('(process/@spid)[1]', 'integer'),
@@ -2087,7 +2172,12 @@ SELECT
object_id = c.value('(data[@name="object_id"]/value/text())[1]', 'integer'),
transaction_id = c.value('(data[@name="transaction_id"]/value/text())[1]', 'bigint'),
resource_owner_type = c.value('(data[@name="resource_owner_type"]/text)[1]', 'nvarchar(256)'),
- monitor_loop = c.value('(//@monitorLoop)[1]', 'integer'),
+ /* ISNULL: a BPR with no monitorLoop attribute otherwise NULLs this,
+ which fails the NOT NULL #session_leads inserts and turns every
+ monitor_loop equality UNKNOWN, silently dropping the Top Lead
+ Blocker rollup. Real loop counters are large positive numbers,
+ so 0 cannot collide. */
+ monitor_loop = ISNULL(c.value('(//@monitorLoop)[1]', 'integer'), 0),
blocking_spid = bg.value('(process/@spid)[1]', 'integer'),
blocking_ecid = bg.value('(process/@ecid)[1]', 'integer'),
blocked_spid = bd.value('(process/@spid)[1]', 'integer'),
@@ -2667,26 +2757,69 @@ BEGIN
IF DB_NAME(@resolve_database_id) IS NOT NULL
AND DATABASEPROPERTYEX(DB_NAME(@resolve_database_id), 'Status') = N'ONLINE'
BEGIN
- SET @resolve_sql = N'
- BEGIN TRY
- UPDATE
- b
- SET
- b.resource_object_id = pi.object_id
+ /*
+ One DMV call per distinct page, each in its own TRY/CATCH.
+ dm_db_page_info RAISES for a deallocated or out-of-range
+ page rather than returning NULL, so the previous set-based
+ CROSS APPLY died on the first bad page and its CATCH
+ swallowed the whole database's resolution — one stale page
+ left every other page in that database unresolved. Per-page,
+ a bad page loses only itself.
+ */
+ SET @page_resolution_pages =
+ CURSOR
+ LOCAL FAST_FORWARD
+ FOR
+ SELECT DISTINCT
+ b.resource_file_id,
+ b.resource_page_id
FROM #blocks AS b
- CROSS APPLY sys.dm_db_page_info(b.resource_database_id, b.resource_file_id, b.resource_page_id, ''LIMITED'') AS pi
- WHERE b.lock_type IN (''PAGE'', ''RID'')
+ WHERE b.lock_type IN ('PAGE', 'RID')
AND b.resource_database_id = @resolve_database_id
- OPTION(RECOMPILE);
- END TRY
- BEGIN CATCH
- /* no VIEW DATABASE STATE / page reallocated -> leave for labeling */
- END CATCH;';
-
- EXECUTE sys.sp_executesql
- @resolve_sql,
- N'@resolve_database_id integer',
- @resolve_database_id;
+ AND b.resource_file_id IS NOT NULL
+ AND b.resource_page_id IS NOT NULL;
+
+ OPEN @page_resolution_pages;
+ FETCH NEXT FROM @page_resolution_pages INTO @resolve_file_id, @resolve_page_id;
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ SET @resolve_object_id = NULL;
+ SET @resolve_sql = N'
+ BEGIN TRY
+ SELECT
+ @resolve_object_id = pi.object_id
+ FROM sys.dm_db_page_info(@resolve_database_id, @resolve_file_id, @resolve_page_id, ''LIMITED'') AS pi;
+ END TRY
+ BEGIN CATCH
+ /* no VIEW DATABASE STATE / page reallocated -> leave this page for labeling */
+ END CATCH;';
+
+ EXECUTE sys.sp_executesql
+ @resolve_sql,
+ N'@resolve_database_id integer,
+ @resolve_file_id integer,
+ @resolve_page_id bigint,
+ @resolve_object_id integer OUTPUT',
+ @resolve_database_id,
+ @resolve_file_id,
+ @resolve_page_id,
+ @resolve_object_id OUTPUT;
+
+ IF @resolve_object_id IS NOT NULL
+ BEGIN
+ UPDATE
+ b
+ SET
+ b.resource_object_id = @resolve_object_id
+ FROM #blocks AS b
+ WHERE b.lock_type IN ('PAGE', 'RID')
+ AND b.resource_database_id = @resolve_database_id
+ AND b.resource_file_id = @resolve_file_id
+ AND b.resource_page_id = @resolve_page_id;
+ END;
+
+ FETCH NEXT FROM @page_resolution_pages INTO @resolve_file_id, @resolve_page_id;
+ END;
END;
FETCH NEXT FROM @page_resolution_dbs INTO @resolve_database_id;
END;
@@ -2732,6 +2865,29 @@ SET
FROM #blocks AS b
OPTION(RECOMPILE);
+/*
+Inherit the victim's resolved object onto its paired lead-blocker row.
+The blocker legitimately has no waitresource to decode — it holds the
+lock rather than waiting on it — and its event-level object_id is
+unreliable for KEY/RID/PAGE locks, so its row fell through to the
+Unresolved sentinel even though the paired victim row in the SAME
+report already named the exact object. Left unpaired, one physical
+contention showed up TWICE in the per-object rollups: once under the
+real object and once under Unresolved.
+*/
+UPDATE
+ unresolved
+SET
+ unresolved.contentious_object = resolved.contentious_object
+FROM #blocks AS unresolved
+JOIN #blocks AS resolved
+ ON resolved.monitor_loop = unresolved.monitor_loop
+ AND resolved.blocking_desc = unresolved.blocking_desc
+ AND resolved.blocked_desc = unresolved.blocked_desc
+ AND resolved.contentious_object NOT LIKE N'Unresolved%'
+WHERE unresolved.contentious_object LIKE N'Unresolved%'
+OPTION(RECOMPILE);
+
/*Either return results or log to a table*/
SET @dsql = N'
SELECT
@@ -2789,12 +2945,33 @@ AND (b.contentious_object = @object_name
/* Add the WHERE clause only for table logging */
IF @log_to_table = 1
BEGIN
- SET @mdsql =
+ DECLARE
+ @start_date_fallback datetime2(7) =
+ ISNULL
+ (
+ @start_date_original,
+ DATEADD(DAY, -7, SYSDATETIME())
+ );
+
+ /*
+ Substituting into a separate variable keeps @mdsql a pristine
+ template — the old code REPLACEd the name in and then tried to
+ REPLACE it back out afterwards, which breaks the template forever
+ if the table name happens to contain the placeholder text. The
+ literal placeholder gets the quote-doubled name because it lands
+ inside a quoted string; the identifier position takes it raw.
+ */
+ SET @mdsql_execute =
REPLACE
(
REPLACE
(
- @mdsql,
+ REPLACE
+ (
+ @mdsql,
+ '{table_check_literal}',
+ REPLACE(@log_table_blocking, N'''', N'''''')
+ ),
'{table_check}',
@log_table_blocking
),
@@ -2802,25 +2979,21 @@ BEGIN
'event_time'
);
- IF @debug = 1 BEGIN PRINT @mdsql; END;
+ IF @debug = 1 BEGIN PRINT @mdsql_execute; END;
+ /*
+ The fallback rides in as the caller's LOCAL-domain window start —
+ @start_date_original is the raw parameter before the UTC shift,
+ matching the local-time event_time column the watermark compares
+ against; when the caller relied on the defaults, seven days back
+ mirrors the proc's own default window.
+ */
EXECUTE sys.sp_executesql
- @mdsql,
- N'@max_event_time datetime2(7) OUTPUT',
- @max_event_time OUTPUT;
-
- SET @mdsql =
- REPLACE
- (
- REPLACE
- (
- @mdsql,
- @log_table_blocking,
- '{table_check}'
- ),
- 'event_time',
- '{date_column}'
- );
+ @mdsql_execute,
+ N'@max_event_time datetime2(7) OUTPUT,
+ @fallback_date datetime2(7)',
+ @max_event_time OUTPUT,
+ @fallback_date = @start_date_fallback;
SET @dsql += N'
AND b.event_time > @max_event_time';
@@ -3173,10 +3346,15 @@ BEGIN
FROM #blocked
WHERE event_time IS NOT NULL;
- /* Use original dates if no data found */
+ /*
+ Use original dates if no data found. The originals are NULL when
+ the caller relied on the default window, and a NULL here
+ concatenated the entire header finding away on an empty capture —
+ so fall through to the always-populated defaulted dates.
+ */
SELECT
- @actual_start_date = ISNULL(@actual_start_date, @start_date_original),
- @actual_end_date = ISNULL(@actual_end_date, @end_date_original),
+ @actual_start_date = ISNULL(@actual_start_date, ISNULL(@start_date_original, @start_date)),
+ @actual_end_date = ISNULL(@actual_end_date, ISNULL(@end_date_original, @end_date)),
@actual_event_count = ISNULL(@actual_event_count, 0);
IF @debug = 1
@@ -3246,7 +3424,7 @@ BEGIN
N'Database Locks',
finding =
N'The database ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N' has been involved in ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' blocking sessions.',
@@ -3358,7 +3536,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' select queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3406,7 +3584,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' repeatable read queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3448,7 +3626,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' serializable queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3490,7 +3668,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' sleeping queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3532,7 +3710,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' background queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3574,7 +3752,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' done queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3616,7 +3794,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' compile locks blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3658,7 +3836,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' application locks blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3700,7 +3878,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' implicit transaction queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3742,7 +3920,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' user transaction queries involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -3784,7 +3962,7 @@ BEGIN
N'There have been ' +
CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) +
N' auto stats updates involved in blocking sessions in ' +
- b.database_name +
+ ISNULL(b.database_name, N'unknown') +
N'.',
sort_order =
ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC)
@@ -4016,27 +4194,36 @@ BEGIN
JOIN #session_leads AS sl
ON sl.monitor_loop = b.monitor_loop
AND sl.session_desc = b.blocking_desc
- CROSS APPLY
- (
- SELECT
- contentious_object =
- ISNULL
- (
- OBJECT_SCHEMA_NAME(b.object_id, b.database_id) +
- N'.' +
- OBJECT_NAME(b.object_id, b.database_id),
- N''
- )
- ) AS co
WHERE
(
b.database_name = @database_name
OR @database_name IS NULL
)
+ /*
+ The object filter reads the wait_resource-decoded
+ contentious_object from #blocks — the SAME resolution every
+ other check filters on — via EXISTS so repeat BPR fires for one
+ victim cannot fan this row out. It used to re-derive the name
+ from the event-level object_id, which is unreliable for
+ KEY/RID/PAGE locks (the resolution comment on #blocks says as
+ much), so OBJECT_NAME returned NULL, the name collapsed to '',
+ and any real @object_name filtered the Top Lead Blocker finding
+ out of existence while every other check still showed the
+ object.
+ */
AND
(
- co.contentious_object = @object_name
- OR @object_name IS NULL
+ @object_name IS NULL
+ OR EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #blocks AS bck
+ WHERE bck.monitor_loop = b.monitor_loop
+ AND bck.blocked_desc = b.blocked_desc
+ AND bck.activity = 'blocked'
+ AND bck.contentious_object = @object_name
+ )
)
),
lead_sql AS
@@ -4285,19 +4472,22 @@ BEGIN
pv.wait_time_ms
)
) /
+ /*
+ The denominator is a correlated subquery over
+ the whole per_victim set — the same grand total
+ the HAVING threshold uses — because a windowed
+ SUM() OVER () here is computed AFTER the HAVING
+ filter, so the "% of total" was a share of only
+ the surviving groups and overstated every
+ percentage.
+ */
NULLIF
(
- SUM
(
- SUM
- (
- CONVERT
- (
- bigint,
- pv.wait_time_ms
- )
- )
- ) OVER (),
+ SELECT
+ SUM(CONVERT(bigint, pv2.wait_time_ms))
+ FROM per_victim AS pv2
+ ),
0
)
)
@@ -4414,6 +4604,14 @@ BEGIN
OR @database_name IS NULL)
AND (b.contentious_object = @object_name
OR @object_name IS NULL)
+ /*
+ Victim rows only: blocking-activity rows carry the blocker's
+ OWN wait (a WAITFOR, an I/O, being blocked elsewhere), which is
+ not lock wait on this resource — and both activities share the
+ event's transaction_id, so without this the MAX could pick the
+ blocker's unrelated wait and report it as lock wait time.
+ */
+ AND b.activity = 'blocked'
GROUP BY
b.database_name,
b.transaction_id
@@ -4502,6 +4700,8 @@ BEGIN
OR @database_name IS NULL)
AND (b.contentious_object = @object_name
OR @object_name IS NULL)
+ /* Victim rows only — same reasoning as check_id 1000 above. */
+ AND b.activity = 'blocked'
GROUP BY
b.database_name,
b.contentious_object,
From ae32a4c4bfca0c1033862e48a023fbdf35991d74 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 14 Jul 2026 07:14:48 -0400
Subject: [PATCH 08/60] sp_IndexCleanup: fix twelve verified defects in index
analysis and generated DDL
This procedure emits cleanup scripts that users execute against
production, so a wrong recommendation is not a cosmetic bug - it
destroys indexes. A multi-agent hunt with adversarial verification
reproduced fifteen defects by building fixtures, running the proc,
executing the emitted DDL, and diffing the index metadata. Twelve are
fixed here. A second adversarial audit of these fixes caught two HIGH
regressions the fixes themselves introduced; both are corrected below.
Recommendations that destroyed data:
- A filtered index could be promoted to replace a UNIQUE CONSTRAINT.
The emitted script dropped the constraint and created a filtered
unique index in its place, which enforces uniqueness only over rows
matching its predicate - a duplicate outside the filter then
inserted cleanly. Filtered indexes are now excluded from constraint
replacement.
- A constraint-backed index could be chosen as the wider target of an
include merge. The emitted CREATE INDEX ... DROP_EXISTING against a
constraint fails outright (Msg 1907), but the paired DISABLE of the
subset still ran, silently removing a covering index nothing
replaced. Constraints are now excluded as merge targets, with a
backstop on the script generator.
- For partitioned indexes, the partitioning column rides along in
sys.index_columns at key_ordinal = 0 when it is not a real key, and
the key-column aggregations swept it in as a phantom LEADING key. The
emitted DROP_EXISTING rebuild therefore re-keyed the very index the
tool told the user to KEEP - (a, b) became (pcol, a, b), so it could
no longer seek on a. It also poisoned duplicate and subset hashing,
producing false matches. Five aggregations now filter on
key_ordinal > 0, matching the guard the proc already used elsewhere.
- Removing that phantom key exposed a second hazard it had been
accidentally masking: a non-aligned UNIQUE CONSTRAINT paired with a
partition-aligned index on the same keys now matched, and the
emitted CREATE UNIQUE failed with Msg 1908 (a unique index's
partitioning column must be part of its key) while the paired DROP
CONSTRAINT succeeded - leaving no uniqueness at all. Any index whose
partitioning column is absent from its key is now excluded from
unique promotion. The aligned case still works.
Filters and parameters:
- @min_reads and @min_writes were inert unless both were set: each was
OR'd against the other, and the unset one defaulted to zero, so
SUM(...) >= 0 was always true. Each term is now gated on its own
threshold.
- Bit parameters were not NULL-guarded, and the defaults were applied
too late to matter. A NULL @dedupe_only slipped past the low-uptime
safety check (NULL = 0 is UNKNOWN, so it never fired), and the
procedure went on to recommend dropping unused indexes based on a few
days of usage statistics. The defaults now run ahead of every
consumer, including that guard.
- Database names containing &, < or > crashed the whole run: the
comma-separated include and exclude lists are split by building an
XML document, and those characters made it malformed. They are now
escaped before the document is built (ampersand first, or the
escaping escapes itself).
- PARSENAME was used to strip brackets from @database_name, but it also
parses dotted names, so a database called Foo.Bar resolved to Bar.
Replaced with a true surrounding-bracket strip.
- @min_rows compared each partition individually while the object
pre-filter summed them, so the two disagreed about what the threshold
meant. Both now sum.
- A partition column needing brackets produced invalid DDL in generated
ON clauses; it is now quoted. The SUMMARY row rendered NULL instead
of 0 for an empty scope.
Verified: installs clean on SQL Server 2016 through 2025; a partitioned
keeper now emits ([a], [b]) INCLUDE ([c]) ... ON [scheme]([pcol])
instead of ([pcol], [a], [b]); both unique-constraint hazards emit
nothing while legitimate merges, subset disables, and aligned
constraint replacements still emit; a NULL @dedupe_only now trips the
seven-day uptime protection instead of bypassing it; & in a database
list no longer crashes the run.
Not changed, by author's decision: rebuild scripts continue to set
FILLFACTOR = 100 and omit PAD_INDEX / ALLOW_PAGE_LOCKS /
STATISTICS_NORECOMPUTE, and IGNORE_DUP_KEY remains outside the
duplicate hash.
Co-Authored-By: Claude Fable 5
---
sp_IndexCleanup/sp_IndexCleanup.sql | 329 +++++++++++++++++++++++++---
1 file changed, 301 insertions(+), 28 deletions(-)
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index 97087c74..5626be63 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -105,6 +105,29 @@ BEGIN TRY
RETURN;
END;
+ /*
+ Default the bit parameters when a caller passes NULL — an
+ uninitialized bit variable, most often. Every downstream test is
+ "= 0" or "= 1", and NULL satisfies neither.
+
+ This has to run HERE, ahead of every consumer, and @dedupe_only is
+ why. The low-uptime guard below both READS and WRITES it:
+
+ IF @uptime_days <= 7 AND @dedupe_only = 0 SET @dedupe_only = 1;
+
+ With NULL, "@dedupe_only = 0" is UNKNOWN, so that safety guard never
+ fires — and defaulting the parameter any later means the procedure
+ goes on to recommend dropping unused indexes based on a few days of
+ usage stats, with the protection silently skipped. @help and @debug
+ are consumed just below too, so a late default would be dead code
+ for them.
+ */
+ SELECT
+ @get_all_databases = ISNULL(@get_all_databases, 0),
+ @dedupe_only = ISNULL(@dedupe_only, 0),
+ @debug = ISNULL(@debug, 0),
+ @help = ISNULL(@help, 0);
+
/*
Help section, for help.
Will become more helpful when out of beta.
@@ -1079,7 +1102,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
N'' +
REPLACE
(
- @include_databases,
+ /*
+ Escape the XML metacharacters before splicing
+ the list into a document. A database name is a
+ delimited identifier, so [Sales & Marketing] is
+ legal, and an unescaped & < or > made the
+ concatenated string malformed XML — CONVERT
+ threw 9411 and killed the whole run. The
+ ampersand MUST be escaped first: doing < first
+ would produce <, whose & would then be
+ escaped again into <. .value() decodes
+ the entities on the way back out, so the real
+ name comes through intact.
+ */
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ @include_databases,
+ N'&',
+ N'&'
+ ),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
N',',
N''
) +
@@ -1123,7 +1174,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
N'' +
REPLACE
(
- @exclude_databases,
+ /*
+ Escape the XML metacharacters before splicing
+ the list into a document. A database name is a
+ delimited identifier, so [Sales & Marketing] is
+ legal, and an unescaped & < or > made the
+ concatenated string malformed XML — CONVERT
+ threw 9411 and killed the whole run. The
+ ampersand MUST be escaped first: doing < first
+ would produce <, whose & would then be
+ escaped again into <. .value() decodes
+ the entities on the way back out, so the real
+ name comes through intact.
+ */
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ @exclude_databases,
+ N'&',
+ N'&'
+ ),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
N',',
N''
) +
@@ -1214,10 +1293,31 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SET @database_name = DB_NAME();
END;
- /* Strip brackets if user supplied them */
- IF @database_name IS NOT NULL
+ /*
+ Strip brackets if user supplied them.
+
+ Not PARSENAME: that parses dotted multi-part names, so a real
+ database called Foo.Bar came back as just "Bar" and matched
+ nothing (or worse, the wrong database). Strip only genuine
+ surrounding brackets, and un-escape the doubled ]] that
+ QUOTENAME would have produced inside them.
+ */
+ IF @database_name IS NOT NULL
+ AND LEFT(@database_name, 1) = N'['
+ AND RIGHT(@database_name, 1) = N']'
BEGIN
- SET @database_name = PARSENAME(@database_name, 1);
+ SET @database_name =
+ REPLACE
+ (
+ SUBSTRING
+ (
+ @database_name,
+ 2,
+ LEN(@database_name) - 2
+ ),
+ N']]',
+ N']'
+ );
END;
/* Single database mode */
@@ -1689,6 +1789,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
to analyze every index, including never-used ones (the whole point of the tool).
@min_reads and @min_writes are validated to non-NULL, non-negative above.
*/
+ /*
+ Each term is gated on its own threshold being set. Without that gate,
+ a caller who set only @min_reads still got SUM(user_updates) >= 0 as
+ the other half of the OR - always true - so the whole filter did
+ nothing. The OR itself is intended: an index counts as used if it
+ clears EITHER floor.
+ */
IF @min_reads > 0
OR @min_writes > 0
BEGIN
@@ -1708,9 +1815,15 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
GROUP BY
ius.object_id
HAVING
- SUM(ius.user_seeks + ius.user_scans + ius.user_lookups) >= @min_reads
+ (
+ @min_reads > 0
+ AND SUM(ius.user_seeks + ius.user_scans + ius.user_lookups) >= @min_reads
+ )
OR
- SUM(ius.user_updates) >= @min_writes
+ (
+ @min_writes > 0
+ AND SUM(ius.user_updates) >= @min_writes
+ )
)';
END;
@@ -2358,6 +2471,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND fo.object_id = us.object_id
);
+ /*
+ The @min_rows filter in this batch SUMs row_count across partitions,
+ matching the object pre-filter. Comparing each partition row on its
+ own meant a partitioned table whose TOTAL rows cleared @min_rows was
+ still analyzed by the pre-filter but then dropped here unless some
+ single partition cleared the floor alone - the two filters disagreed
+ about what "@min_rows" means.
+ */
SELECT
@sql = N'
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
@@ -2497,7 +2618,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
N'.sys.dm_db_partition_stats ps
WHERE ps.object_id = i.object_id
AND ps.index_id IN (0, 1)
- AND ps.row_count >= @min_rows
+ GROUP BY
+ ps.object_id
+ HAVING
+ SUM(ps.row_count) >= @min_rows
)'
);
@@ -2723,7 +2847,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
SELECT
N'', '' +
- c.name
+ QUOTENAME(c.name)
FROM ' + QUOTENAME(@current_database_name) + N'.sys.index_columns AS ic
JOIN ' + QUOTENAME(@current_database_name) + N'.sys.columns AS c
ON c.object_id = ic.object_id
@@ -2847,6 +2971,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id2.object_id = id1.object_id
AND id2.index_id = id1.index_id
AND id2.is_included_column = 0
+ /*
+ A partitioned index's partitioning column rides along at
+ key_ordinal = 0 when it is not a real key. Without this it was
+ emitted as a phantom LEADING key, corrupting rebuilt indexes
+ and poisoning duplicate/subset hashing.
+ */
+ AND id2.key_ordinal > 0
GROUP BY
id2.column_name,
id2.is_descending_key,
@@ -2942,6 +3073,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id2.object_id = id1.object_id
AND id2.index_id = id1.index_id
AND id2.is_included_column = 0
+ /*
+ A partitioned index's partitioning column rides along at
+ key_ordinal = 0 when it is not a real key. Without this it was
+ emitted as a phantom LEADING key, corrupting rebuilt indexes
+ and poisoning duplicate/subset hashing.
+ */
+ AND id2.key_ordinal > 0
GROUP BY
id2.column_name,
id2.is_descending_key,
@@ -3410,6 +3548,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id2_inner.index_hash = ia2_inner.index_hash
AND id2_inner.is_eligible_for_dedupe = 1
)
+ /* Same constraint exclusion as the outer join below: a
+ unique constraint cannot serve as the merge target. */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_details AS id2_uc_inner
+ WHERE id2_uc_inner.index_hash = ia2_inner.index_hash
+ AND id2_uc_inner.is_unique_constraint = 1
+ )
AND NOT EXISTS
(
SELECT
@@ -3439,7 +3587,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia1.is_unique = 0
WHERE ia1.consolidation_rule IS NULL /* Not already processed */
AND ia2.consolidation_rule IS NULL /* Not already processed */
- /* Don't disable unique constraints — but allow them as the wider (target) index */
+ /* Don't disable unique constraints */
AND NOT EXISTS
(
SELECT
@@ -3448,6 +3596,23 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id1_uc.index_hash = ia1.index_hash
AND id1_uc.is_unique_constraint = 1
)
+ /*
+ And don't pick one as the wider TARGET either. Merging a subset's
+ includes into a constraint-backed index emits CREATE INDEX ... WITH
+ (DROP_EXISTING = ON) against the constraint's index, which SQL
+ Server refuses outright (Msg 1907) — while the paired DISABLE of
+ the subset still runs, silently losing a covering index the
+ constraint never absorbed. Passing on the dedupe entirely is the
+ safe trade: the subset survives, the constraint stays untouched.
+ */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_details AS id2_uc
+ WHERE id2_uc.index_hash = ia2.index_hash
+ AND id2_uc.is_unique_constraint = 1
+ )
AND EXISTS
(
SELECT
@@ -3838,6 +4003,31 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
FROM #index_analysis AS ia1
WHERE ia1.consolidation_rule IS NULL /* Not already processed */
AND ia1.action IS NULL /* Not already processed by earlier rules */
+ /*
+ A filtered index can never stand in for a unique constraint: the
+ constraint enforces uniqueness over EVERY row, the filtered index
+ only over rows matching its predicate. Promoting one and dropping
+ the constraint silently un-enforced uniqueness outside the filter.
+ */
+ AND ia1.filter_definition IS NULL
+ /*
+ A partitioned index can only be made UNIQUE if its partitioning
+ column is part of its key — SQL Server rejects anything else with
+ Msg 1908. The key list correctly excludes a partition column that is
+ not a real key, but the ON clause still partitions by it, so
+ promoting such an index emits a CREATE UNIQUE that can never run
+ while the paired DROP CONSTRAINT runs fine: the constraint goes and
+ nothing replaces it. Pass on the recommendation entirely.
+ */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #partition_stats AS ps_uq
+ WHERE ps_uq.index_hash = ia1.index_hash
+ AND ps_uq.partition_columns IS NOT NULL
+ AND CHARINDEX(ps_uq.partition_columns, ia1.key_columns) = 0
+ )
AND EXISTS
(
/* Find nonclustered indexes */
@@ -3922,6 +4112,20 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ON ia_nc.scope_hash = ia_uc.scope_hash /* Same database and object */
AND ia_nc.index_name <> ia_uc.index_name /* Different index */
AND ia_uc.key_columns = ia_nc.key_columns /* Verify key columns EXACT match */
+ /* A filtered index only enforces uniqueness inside its predicate;
+ it can never replace a full-table unique constraint. */
+ AND ia_nc.filter_definition IS NULL
+ /* Same Msg 1908 guard as Rule 7: a partitioned index whose
+ partition column is not in its key cannot be made unique. */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #partition_stats AS ps_uq
+ WHERE ps_uq.index_hash = ia_nc.index_hash
+ AND ps_uq.partition_columns IS NOT NULL
+ AND CHARINDEX(ps_uq.partition_columns, ia_nc.key_columns) = 0
+ )
WHERE NOT EXISTS
(
/* Don't propose replacing a unique constraint that backs an inbound
@@ -3964,23 +4168,34 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
JOIN #index_details AS id_nc /* Join to get nonclustered index details */
ON id_nc.index_hash = ia_nc.index_hash
AND id_nc.is_unique_constraint = 0 /* This is not a unique constraint */
- WHERE
- /* Two conditions for matching:
- 1. Index key columns exactly match a unique constraint's key columns
- 2. A unique constraint is already marked for DISABLE and has this index as target */
- EXISTS
- (
- /* Find unique constraint with matching keys that should be disabled */
- SELECT
- 1/0
- FROM #index_analysis AS ia_uc
- JOIN #index_details AS id_uc
- ON id_uc.index_hash = ia_uc.index_hash
- AND id_uc.is_unique_constraint = 1
- WHERE ia_uc.scope_hash = ia_nc.scope_hash
- /* Check that both indexes have EXACTLY the same key columns */
- AND ia_uc.key_columns = ia_nc.key_columns
- )
+ /* Same filtered-index guard as the DISABLE marking above. */
+ WHERE ia_nc.filter_definition IS NULL
+ /* Same Msg 1908 guard as the DISABLE marking above. */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #partition_stats AS ps_uq
+ WHERE ps_uq.index_hash = ia_nc.index_hash
+ AND ps_uq.partition_columns IS NOT NULL
+ AND CHARINDEX(ps_uq.partition_columns, ia_nc.key_columns) = 0
+ )
+ /* Two conditions for matching:
+ 1. Index key columns exactly match a unique constraint's key columns
+ 2. A unique constraint is already marked for DISABLE and has this index as target */
+ AND EXISTS
+ (
+ /* Find unique constraint with matching keys that should be disabled */
+ SELECT
+ 1/0
+ FROM #index_analysis AS ia_uc
+ JOIN #index_details AS id_uc
+ ON id_uc.index_hash = ia_uc.index_hash
+ AND id_uc.is_unique_constraint = 1
+ WHERE ia_uc.scope_hash = ia_nc.scope_hash
+ /* Check that both indexes have EXACTLY the same key columns */
+ AND ia_uc.key_columns = ia_nc.key_columns
+ )
OPTION(RECOMPILE);
/* CRITICAL: Ensure that only the unique constraints that exactly match get this treatment */
@@ -5029,6 +5244,41 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE ia.action IN (N'MERGE INCLUDES', N'MAKE UNIQUE')
/* Only create merge scripts for the indexes that should remain after merging */
AND ia.target_index_name IS NULL
+ /*
+ Backstop behind the Rule 3 target exclusions: CREATE INDEX ... WITH
+ (DROP_EXISTING = ON) can never succeed against a constraint-backed
+ index (Msg 1907), so no matter what upstream rules decide, never
+ emit a merge script for one.
+ */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_details AS id_uc_guard
+ WHERE id_uc_guard.index_hash = ia.index_hash
+ AND id_uc_guard.is_unique_constraint = 1
+ )
+ /*
+ Second backstop, behind the Rule 7 / 7.5 guards: any script that
+ comes out of here as CREATE UNIQUE must not partition on a column
+ outside its own key, or SQL Server rejects it with Msg 1908 — and
+ the paired DROP CONSTRAINT would still succeed, leaving the table
+ with no uniqueness at all.
+ */
+ AND
+ (
+ ia.action <> N'MAKE UNIQUE'
+ AND ia.is_unique = 0
+ OR NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #partition_stats AS ps_uq_guard
+ WHERE ps_uq_guard.index_hash = ia.index_hash
+ AND ps_uq_guard.partition_columns IS NOT NULL
+ AND CHARINDEX(ps_uq_guard.partition_columns, ia.key_columns) = 0
+ )
+ )
OPTION(RECOMPILE);
/* Debug which indexes are getting MERGE scripts */
@@ -5282,6 +5532,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id2.object_id = fo.object_id
AND id2.index_id = fo.index_id
AND id2.is_included_column = 0
+ /*
+ A partitioned index's partitioning column rides along at
+ key_ordinal = 0 when it is not a real key. Without this it was
+ emitted as a phantom LEADING key, corrupting rebuilt indexes
+ and poisoning duplicate/subset hashing.
+ */
+ AND id2.key_ordinal > 0
GROUP BY
id2.column_name,
id2.is_descending_key,
@@ -5334,6 +5591,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id2.object_id = fo.object_id
AND id2.index_id = fo.index_id
AND id2.is_included_column = 0
+ /*
+ A partitioned index's partitioning column rides along at
+ key_ordinal = 0 when it is not a real key. Without this it was
+ emitted as a phantom LEADING key, corrupting rebuilt indexes
+ and poisoning duplicate/subset hashing.
+ */
+ AND id2.key_ordinal > 0
GROUP BY
id2.column_name,
id2.is_descending_key,
@@ -5394,6 +5658,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id2.object_id = fo.object_id
AND id2.index_id = fo.index_id
AND id2.is_included_column = 0
+ /*
+ A partitioned index's partitioning column rides along at
+ key_ordinal = 0 when it is not a real key. Without this it was
+ emitted as a phantom LEADING key, corrupting rebuilt indexes
+ and poisoning duplicate/subset hashing.
+ */
+ AND id2.key_ordinal > 0
GROUP BY
id2.column_name,
id2.is_descending_key,
@@ -6813,7 +7084,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
END,
schema_name = 'ALWAYS TEST THESE RECOMMENDATIONS',
table_name = 'IN A NON-PRODUCTION ENVIRONMENT FIRST!',
- tables_analyzed = FORMAT(irs.tables_analyzed, 'N0'),
+ /* ISNULL to match the DATABASE and TABLE levels below: an
+ empty scope rendered this as NULL instead of 0 */
+ tables_analyzed = FORMAT(ISNULL(irs.tables_analyzed, 0), 'N0'),
total_indexes = FORMAT(ISNULL(irs.index_count, 0), 'N0'),
removable_indexes = FORMAT(ISNULL(irs.indexes_to_disable, 0), 'N0'),
mergeable_indexes = FORMAT(ISNULL(irs.indexes_to_merge, 0), 'N0'),
From 1ed78c8524d367ee60a826445384b5cf348d853b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 14 Jul 2026 09:43:23 -0400
Subject: [PATCH 09/60] sp_LogHunter: fix nine defects that made it report "all
clear" when it wasn't
This procedure is a detector, so its dangerous failure mode is silence:
an empty result set reads like good news, and nobody investigates. Every
fix here closes a path where it found less than it should have.
Silent no-op:
- @days_back = NULL with no date range made DATEADD return NULL, which
made every generated command NULL, and sys.sp_executesql runs a NULL
batch without complaint. The procedure opened no log files, matched
nothing, and returned a clean empty result. It now falls back to the
documented -7 default. A large negative @days_back is also clamped, so
reaching for "everything" no longer overflows DATEADD and aborts.
The date window:
- Supplying only @start_date (or only @end_date) left @days_back alive at
-7. The check that retires it required BOTH dates, but the procedure
fills in whichever one is missing immediately afterwards - so the check
ran too early and always lost. The log file pruner then deleted every
archive older than a week, meaning a search for an incident three
months ago quietly threw away the only file containing it. The check
now runs after the fixups and fires if either date is supplied.
- The pruner itself deleted any archive whose timestamp fell outside the
window. But sys.sp_enumerrorlogs reports each file's LAST write, so a
file stamped after @end_date still holds entries from before it - and
that file is exactly the one straddling the end of the window, the most
likely place for the incident being investigated. An archive's start is
the previous archive's last write, so the pruner now computes the real
span of each file and drops one only if it ended before the window
opened or began after it closed.
- #enum.log_date was typed date, flooring each archive's last write to
midnight. It is now datetime - but sp_enumerrorlogs also truncates that
value to the minute, and the file's true final entry lands in the
following 59 seconds, so the start bound carries a one-minute grace.
Without it, exact comparison prunes archives that really do reach into
the window: on a test server that dropped a "significant part of sql
server process memory has been paged out" warning.
Searching:
- Every generated command passed a literal " " as xp_readerrorlog's
second search string, which the extended procedure ANDs with the first.
Every search therefore silently required the matched line to contain a
space, including @custom_message. It now passes NULL.
- @first_log_only kept archives 0 and 1. Archive 0 is the current log, so
"only the first log" was searching two of them.
- @start_date and @end_date were handed to xp_readerrorlog with CONVERT
style 0, which drops seconds and lost entries in the window's final
partial minute. Now style 121.
Honesty:
- @language_id is validated against sys.messages and then never used: all
88 search strings are English literals matched against raw log text.
Pointed at a non-English server it found nothing and explained nothing.
It now warns that the corpus will not translate and suggests
@custom_message, rather than handing back an empty result set that
reads like a clean bill of health.
Verified on SQL Server 2016 through 2025 across every parameter mode. The
search corpus itself is unchanged: a few strings that cannot match, and a
few that match too much, are editorial calls left to the author.
Co-Authored-By: Claude Fable 5
---
sp_LogHunter/sp_LogHunter.sql | 158 ++++++++++++++++++++++++++++++----
1 file changed, 139 insertions(+), 19 deletions(-)
diff --git a/sp_LogHunter/sp_LogHunter.sql b/sp_LogHunter/sp_LogHunter.sql
index f6f6b90d..680722db 100644
--- a/sp_LogHunter/sp_LogHunter.sql
+++ b/sp_LogHunter/sp_LogHunter.sql
@@ -213,6 +213,18 @@ BEGIN
RETURN;
END;
+ /*
+ Say so, rather than pretending. @language_id is validated and then
+ never used: every search string below is an English literal matched
+ against the error log's raw text, so pointing this at a non-English
+ server finds nothing and says nothing about why. Warn instead of
+ quietly handing back an empty result set that reads like good news.
+ */
+ IF @language_id <> 1033
+ BEGIN
+ RAISERROR(N'The search strings are English literals, so @language_id = %i will not translate them. Expect few or no results on a non-English server; use @custom_message to search in your own language.', 10, 1, @language_id) WITH NOWAIT;
+ END;
+
/*Fix days back a little bit*/
IF @days_back = 0
BEGIN
@@ -226,12 +238,30 @@ BEGIN
@days_back *= -1;
END;
- IF @start_date IS NOT NULL
- AND @end_date IS NOT NULL
- AND @days_back IS NOT NULL
+ /*
+ A NULL @days_back with no date range is fatal, and silently so: it
+ makes DATEADD return NULL, which makes every generated command NULL,
+ and sys.sp_executesql runs a NULL batch as a no-op. The procedure
+ reads nothing, finds nothing, and reports a clean bill of health on a
+ server that may be on fire. Put it back on the documented default.
+ */
+ IF @days_back IS NULL
+ AND @start_date IS NULL
+ AND @end_date IS NULL
BEGIN
SELECT
- @days_back = NULL;
+ @days_back = -7;
+ END;
+
+ /*
+ Clamp before DATEADD sees it. An absurd @days_back (a caller reaching
+ for "everything") overflows the datetime range and kills the run with
+ an arithmetic error instead of just searching every log we have.
+ */
+ IF @days_back < -36500
+ BEGIN
+ SELECT
+ @days_back = -36500;
END;
/*Fix custom message only if NULL*/
@@ -272,6 +302,28 @@ BEGIN
@start_date = DATEADD(DAY, -7, @end_date);
END;
+ /*
+ Retire @days_back once we are in date-range mode, and do it HERE —
+ after the two fixups above, not before them.
+
+ The check used to require BOTH dates to be present, but a caller who
+ supplies only one gets the other filled in just above. Running the
+ check first meant @days_back survived at its -7 default, and the log
+ file pruner below then deleted every archive older than a week — so
+ searching for an incident from three months ago quietly threw away
+ the only file that contained it and returned nothing.
+ */
+ IF @days_back IS NOT NULL
+ AND
+ (
+ @start_date IS NOT NULL
+ OR @end_date IS NOT NULL
+ )
+ BEGIN
+ SELECT
+ @days_back = NULL;
+ END;
+
/*Debuggo*/
IF @debug = 1
BEGIN
@@ -309,7 +361,14 @@ BEGIN
(
archive integer
PRIMARY KEY CLUSTERED,
- log_date date,
+ /*
+ datetime, not date. sp_enumerrorlogs reports each archive's last
+ write with a time component; truncating it to midnight made an
+ archive that ended this afternoon compare as if it had ended at
+ 00:00, so the window bound below could rule out a file that
+ actually reaches into the window.
+ */
+ log_date datetime,
log_size bigint
);
@@ -334,7 +393,14 @@ BEGIN
N'EXECUTE master.dbo.xp_readerrorlog [@@@], 1, '
+ search_string
+ N', '
- + N'" "'
+ /*
+ NULL, not " ". xp_readerrorlog ANDs its second search
+ string with the first, so passing a literal space quietly
+ required every matched line to contain one - a filter
+ nobody asked for, applied to every search including
+ @custom_message.
+ */
+ + N'NULL'
+ N', '
+ ISNULL(start_date, days_back)
+ N', '
@@ -372,31 +438,85 @@ BEGIN
DELETE
e WITH(TABLOCKX)
FROM #enum AS e
- WHERE e.log_date < DATEADD(DAY, @days_back, SYSDATETIME())
+ /*
+ The one-minute grace is not slop. sp_enumerrorlogs reports each
+ archive's last write truncated to the MINUTE, and the file's real
+ final entry lands somewhere in the following 59 seconds - so the
+ reported stamp always understates when the file actually ends.
+ Comparing it exactly prunes archives that really do reach into
+ the window. (HEAD hid this by typing the column as `date`, which
+ floored everything to midnight and bought a full day of slack by
+ accident.) Since enum <= true_end < enum + 60s, one minute is
+ exactly enough.
+ */
+ WHERE DATEADD(MINUTE, 1, e.log_date) < DATEADD(DAY, @days_back, SYSDATETIME())
AND e.archive > 0
OPTION(RECOMPILE);
END;
- /*filter out log files we won't use, if @start_date and @end_date are set*/
+ /*
+ Filter out log files we won't use, if @start_date and @end_date are set.
+
+ Only the START of the window can rule a log file out, and that is the
+ whole subtlety here. sp_enumerrorlogs reports each archive's LAST
+ write, not its first, so a file stamped "March 10" holds entries
+ reaching back to whenever the previous archive ended. The old
+ predicate also deleted anything stamped AFTER @end_date — which threw
+ away precisely the file that straddles the end of the window, i.e.
+ the one most likely to contain the incident being investigated.
+
+ A file whose last write predates the window truly cannot contain it,
+ so that is the only safe exclusion. Keeping the newer files costs a
+ little extra reading and nothing else: xp_readerrorlog filters by
+ date again on the way out, so no out-of-window rows survive.
+ */
IF @start_date IS NOT NULL
AND @end_date IS NOT NULL
BEGIN
DELETE
e WITH(TABLOCKX)
FROM #enum AS e
- WHERE (e.log_date < CONVERT(date, @start_date)
- OR e.log_date > CONVERT(date, @end_date))
- AND e.archive > 0
+ /*
+ A file's last write is where it ENDS; where it STARTS is the last
+ write of the archive before it (the one rotated out to make room).
+ Archives count upward into the past, so that is the next-higher
+ number. The oldest file has no predecessor and reaches back
+ indefinitely, which a NULL here handles by never matching.
+ */
+ OUTER APPLY
+ (
+ SELECT TOP (1)
+ starts_at = e2.log_date
+ FROM #enum AS e2
+ WHERE e2.archive > e.archive
+ ORDER BY
+ e2.archive
+ ) AS prior
+ WHERE e.archive > 0
+ AND
+ (
+ /*
+ Ended before we started looking. The +1 minute is the same
+ truncation grace as above: sp_enumerrorlogs rounds each
+ archive's last write DOWN to the minute.
+ */
+ DATEADD(MINUTE, 1, e.log_date) < @start_date
+ /*Or did not begin until after we stopped*/
+ OR prior.starts_at > @end_date
+ )
OPTION(RECOMPILE);
END;
- /*maybe you only want the first one anyway*/
+ /*
+ Maybe you only want the first one anyway. Archive 0 IS the current
+ log, so keeping "archive > 1" kept two files, not one.
+ */
IF @first_log_only = 1
BEGIN
DELETE
e WITH(TABLOCKX)
FROM #enum AS e
- WHERE e.archive > 1
+ WHERE e.archive > 0
OPTION(RECOMPILE);
END;
@@ -446,9 +566,9 @@ BEGIN
END +
N'"',
start_date =
- N'"' + CONVERT(nvarchar(30), @start_date) + N'"',
+ N'"' + CONVERT(nvarchar(30), @start_date, 121) + N'"',
end_date =
- N'"' + CONVERT(nvarchar(30), @end_date) + N'"'
+ N'"' + CONVERT(nvarchar(30), @end_date, 121) + N'"'
) AS c
WHERE @custom_message_only = 0
OPTION(RECOMPILE);
@@ -497,9 +617,9 @@ BEGIN
days_back =
N'"' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'"',
start_date =
- N'"' + CONVERT(nvarchar(30), @start_date) + N'"',
+ N'"' + CONVERT(nvarchar(30), @start_date, 121) + N'"',
end_date =
- N'"' + CONVERT(nvarchar(30), @end_date) + N'"'
+ N'"' + CONVERT(nvarchar(30), @end_date, 121) + N'"'
) AS c
WHERE @custom_message_only = 0
OPTION(RECOMPILE);
@@ -530,8 +650,8 @@ BEGIN
the generated batch. */
N'"' + REPLACE(@custom_message, N'"', N'""') + N'"',
N'"' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'"',
- N'"' + CONVERT(nvarchar(30), @start_date) + N'"',
- N'"' + CONVERT(nvarchar(30), @end_date) + N'"'
+ N'"' + CONVERT(nvarchar(30), @start_date, 121) + N'"',
+ N'"' + CONVERT(nvarchar(30), @end_date, 121) + N'"'
)
) AS x (search_string, days_back, start_date, end_date)
WHERE @custom_message LIKE N'_%'
From 4043fd7f415799dacb10f15fcae8b8b422fbc2b5 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 14 Jul 2026 12:36:48 -0400
Subject: [PATCH 10/60] sp_PressureDetector: fix eight defects, add an
external-CPU pressure check
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every fix here closes a way the tool either lost logged data or told a
DBA there was no pressure when there was. A multi-agent hunt with
adversarial verification reproduced each one live; a second adversarial
review of the fixes themselves confirmed them and caught two of my own
mistakes before this commit (an over-doubled quote level and a comment
left inside a dynamic SQL literal that truncated a generated batch).
Logging subsystem:
- @log_table_name_prefix had no NULL guard, unlike the database and
schema names. A NULL (an automation variable that resolved to NULL)
made QUOTENAME(NULL + N'_Waits') NULL, which nulled every CREATE /
INSERT / DELETE command string — and sp_executesql runs a NULL batch
as a silent no-op. The proc returned success having created no tables
and logged nothing, with every result set suppressed because it was in
logging mode. Now defaults to 'PressureDetector'.
- An apostrophe in a log identifier aborted the run at parse time (Msg
102, even at @debug = 0): the fully-qualified name was re-embedded into
the debug RAISERROR's single-quoted literal without doubling its
quotes. Fixed at all nine embed sites.
- The nine assembled-name variables were sysname, but they hold
three-part QUOTENAME'd names; a long-but-legal name truncated and the
CREATE failed partway through setup. Widened to nvarchar(1024), plus an
up-front cap so a prefix too long to fit its suffix in a 128-char
identifier fails loudly instead of half-creating.
Reports "no pressure" when there is some:
- @skip_plan_xml = 1 silently discarded the entire "queries with memory
grants" result set: the outer CASE that builds the plan column had no
ELSE, so skipping the plan XML made the whole @mem_sql NULL. Added the
ELSE the CPU builder already has.
- The perfmon panel filtered on total_per_second <> '0', but that value
is cntr_value / uptime-in-seconds in integer arithmetic, so every
counter smaller than the server's uptime floored to '0' and was
dropped — including the point-in-time pressure gauges (Memory Grants
Pending, Processes Blocked, deadlocks). Filters on the raw counter now.
- A lock timeout while fetching a query plan aborted the whole procedure.
The plan-fetch batches SET LOCK_TIMEOUT to fail fast, but with
XACT_ABORT ON and no handler the resulting error 1222 — plausible under
the very plan-cache churn being diagnosed — took down every section
after it. The four plan-fetch executes are now wrapped in TRY/CATCH and
degrade to a warning.
Wrong numbers:
- Sample-mode disk latency divided bigint stalls by bigint counts inside
a decimal CONVERT, so the integer division truncated every latency to a
whole millisecond (and to 0 on fast disks). Forced decimal division.
- avg_runnable_tasks_count averaged an integer column, truncating
fractional CPU queuing to a whole number; and the runnable percentage
rounded the ratio to two decimals before multiplying by 100, zeroing
every percentage below ~0.5%. Both now compute in decimal.
New check:
- A separate external-CPU signal: the box saturated by processes OTHER
than SQL Server. The scheduler-monitor panel already computed
other-process and total CPU but only ever filtered on SQL's own
utilization, so a machine pegged by another instance / AV / backup
agent reported "no significant CPU usage" while SQL was scheduled off
the CPU. Surfaced as its own result column, and unioned into the
_CPUEvents log table so headless/logged deployments get it too.
Verified on SQL Server 2016 through 2025 across all @what_to_check modes,
sample mode, blocking mode, and logging mode; an apostrophe-bearing log
prefix now creates all nine tables; a NULL prefix defaults instead of
silently logging nothing; low-value perfmon counters that were dropped by
integer underflow now surface; a lock timeout during plan fetch degrades
to a warning instead of aborting the run.
Co-Authored-By: Claude Fable 5
---
sp_PressureDetector/sp_PressureDetector.sql | 253 +++++++++++++++++---
1 file changed, 219 insertions(+), 34 deletions(-)
diff --git a/sp_PressureDetector/sp_PressureDetector.sql b/sp_PressureDetector/sp_PressureDetector.sql
index 96b83c54..66237a49 100644
--- a/sp_PressureDetector/sp_PressureDetector.sql
+++ b/sp_PressureDetector/sp_PressureDetector.sql
@@ -409,6 +409,16 @@ OPTION(MAXDOP 1, RECOMPILE);',
@database_size_out_gb nvarchar(10) = '0',
@total_physical_memory_gb bigint,
@cpu_utilization xml = N'',
+ /*
+ A second, separate CPU signal: the box saturated by processes
+ OTHER than SQL Server. The panel already computes it, but only
+ ever filtered on SQL's own utilization, so a machine pegged by a
+ different instance / AV / backup agent reported "no significant
+ CPU usage" while SQL was being scheduled off the CPU. Reported
+ alongside the SQL-CPU number, not folded into it.
+ */
+ @cpu_utilization_external xml = N'',
+ @pd_catch_msg nvarchar(2048),
@low_memory xml = N'',
@health_history bit =
CASE
@@ -464,15 +474,22 @@ OPTION(MAXDOP 1, RECOMPILE);',
@resource_semaphores nvarchar(max) = N'',
@cpu_threads nvarchar(max) = N'',
/*Log to table stuff*/
- @log_table_waits sysname,
- @log_table_file_metrics sysname,
- @log_table_perfmon sysname,
- @log_table_memory sysname,
- @log_table_cpu sysname,
- @log_table_memory_consumers sysname,
- @log_table_memory_queries sysname,
- @log_table_cpu_queries sysname,
- @log_table_cpu_events sysname,
+ /*
+ nvarchar(1024), not sysname: these hold fully-qualified,
+ QUOTENAME'd three-part names. sysname is 128 chars, so a
+ long-but-legal database/schema/prefix silently truncated the
+ name and the CREATE TABLE then failed partway through setup,
+ after some of the nine tables already existed.
+ */
+ @log_table_waits nvarchar(1024),
+ @log_table_file_metrics nvarchar(1024),
+ @log_table_perfmon nvarchar(1024),
+ @log_table_memory nvarchar(1024),
+ @log_table_cpu nvarchar(1024),
+ @log_table_memory_consumers nvarchar(1024),
+ @log_table_memory_queries nvarchar(1024),
+ @log_table_cpu_queries nvarchar(1024),
+ @log_table_cpu_events nvarchar(1024),
@cleanup_date datetime2(7),
@max_sample_time datetime,
@check_sql nvarchar(max) = N'',
@@ -489,7 +506,31 @@ OPTION(MAXDOP 1, RECOMPILE);',
/* Default database name to current database if not specified */
@log_database_name = ISNULL(@log_database_name, DB_NAME()),
/* Default schema name to dbo if not specified */
- @log_schema_name = ISNULL(@log_schema_name, N'dbo');
+ @log_schema_name = ISNULL(@log_schema_name, N'dbo'),
+ /*
+ Default the prefix too. It was the one log identifier with no
+ guard, so a NULL (an automation variable that resolved to
+ NULL) made QUOTENAME(NULL + N'_Waits') NULL, which nulled
+ every CREATE / INSERT / DELETE command string — and
+ sp_executesql runs a NULL batch as a silent no-op. The proc
+ returned success having created no tables and logged nothing,
+ with every result set suppressed because it was in logging
+ mode. Total silent data loss.
+ */
+ @log_table_name_prefix = ISNULL(@log_table_name_prefix, N'PressureDetector');
+
+ /*
+ Cap the prefix so prefix + the longest suffix (_MemoryConsumers,
+ 16 chars) still fits a 128-char identifier. Past that, QUOTENAME
+ returns NULL for the long leaves and the run half-creates its
+ tables before failing - fail loudly up front instead. DATALENGTH
+ so trailing spaces cannot slip past the limit.
+ */
+ IF DATALENGTH(@log_table_name_prefix) / 2 > 112
+ BEGIN
+ RAISERROR(N'@log_table_name_prefix is limited to 112 characters so the longest table suffix (_MemoryConsumers) still fits in an identifier. Logging will be disabled.', 11, 1) WITH NOWAIT;
+ RETURN;
+ END;
/* Validate database exists */
IF NOT EXISTS
@@ -593,7 +634,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
waiting_tasks_count bigint NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for wait stats logging.'', 0, 1, ''' + @log_table_waits + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for wait stats logging.'', 0, 1, ''' + REPLACE(@log_table_waits, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -642,7 +683,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
io_stall_write_ms bigint NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for file metrics logging.'', 0, 1, ''' + @log_table_file_metrics + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for file metrics logging.'', 0, 1, ''' + REPLACE(@log_table_file_metrics, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -678,7 +719,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
cntr_type bigint NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for perfmon logging.'', 0, 1, ''' + @log_table_perfmon + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for perfmon logging.'', 0, 1, ''' + REPLACE(@log_table_perfmon, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -726,7 +767,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
pool_id integer NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory logging.'', 0, 1, ''' + @log_table_memory + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory logging.'', 0, 1, ''' + REPLACE(@log_table_memory, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -771,7 +812,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
cpu_utilization_over_threshold xml NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU logging.'', 0, 1, ''' + @log_table_cpu + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU logging.'', 0, 1, ''' + REPLACE(@log_table_cpu, N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -805,7 +846,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
memory_consumed_gb decimal(38,2) NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory consumers logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryConsumers') + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory consumers logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryConsumers'), N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -860,7 +901,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
live_query_plan xml NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory queries logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryQueries') + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory queries logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryQueries'), N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -914,7 +955,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
statement_end_offset integer NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU queries logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUQueries') + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU queries logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUQueries'), N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -950,7 +991,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
total_cpu_utilization integer NULL,
PRIMARY KEY CLUSTERED (collection_time, id)
);
- IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU utilization events logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUEvents') + N''') WITH NOWAIT; END;
+ IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU utilization events logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUEvents'), N'''', N'''''') + N''') WITH NOWAIT; END;
END';
EXECUTE sys.sp_executesql
@@ -1944,7 +1985,8 @@ OPTION(MAXDOP 1, RECOMPILE);',
CONVERT
(
decimal(38, 2),
- (fm2.io_stall_read_ms - fm.io_stall_read_ms) /
+ /* 1.0 * forces decimal division; bigint/bigint truncated every latency to a whole ms (and to 0 on fast disks) */
+ 1.0 * (fm2.io_stall_read_ms - fm.io_stall_read_ms) /
(fm2.total_read_count - fm.total_read_count)
)
END,
@@ -1956,7 +1998,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
CONVERT
(
decimal(38, 2),
- (fm2.io_stall_write_ms - fm.io_stall_write_ms) /
+ 1.0 * (fm2.io_stall_write_ms - fm.io_stall_write_ms) /
(fm2.total_write_count - fm.total_write_count)
)
END,
@@ -1969,6 +2011,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
CONVERT
(
decimal(38,2),
+ 1.0 *
(
(fm2.io_stall_read_ms - fm.io_stall_read_ms) +
(fm2.io_stall_write_ms - fm.io_stall_write_ms)
@@ -2269,7 +2312,17 @@ OPTION(MAXDOP 1, RECOMPILE);',
p.total,
p.total_per_second
FROM p
- WHERE p.total_per_second <> N'0'
+ /*
+ Filter on the raw counter, not the per-second STRING.
+ total_per_second is cntr_value / uptime-in-seconds in
+ integer arithmetic, so every counter smaller than the
+ server's uptime floored to '0' and got dropped here —
+ and those are exactly the point-in-time pressure gauges
+ (Memory Grants Pending, Processes Blocked, deadlocks,
+ which sit at single digits). The load step already keeps
+ only cntr_value > 0, so this just stops re-dropping them.
+ */
+ WHERE p.cntr_value > 0
ORDER BY
p.object_name,
p.counter_name,
@@ -3385,6 +3438,15 @@ OPTION(MAXDOP 1, RECOMPILE);',
deqs.query_plan,'
ELSE N''
END
+ /*
+ Without this ELSE, @skip_plan_xml = 1 made the outer
+ CASE return NULL, CONVERT(nvarchar(max), NULL) is NULL,
+ and the whole @mem_sql string collapsed to NULL — so
+ skipping the plan XML silently discarded the entire
+ "queries with memory grants" result set. The CPU
+ builder has this ELSE; this one was missing it.
+ */
+ ELSE N''
END +
N'
deqmg.request_time,
@@ -3472,8 +3534,22 @@ OPTION(MAXDOP 1, RECOMPILE);',
IF @log_to_table = 0
BEGIN
- EXECUTE sys.sp_executesql
- @mem_sql;
+ /*
+ TRY/CATCH so a failure fetching query plans does not take the
+ whole report down with it. This batch SETs LOCK_TIMEOUT to
+ fail fast, but with XACT_ABORT ON and no handler a lock
+ timeout (error 1222 — plausible under the very plan-cache
+ churn being diagnosed) aborted the entire procedure, losing
+ every section after this one. Now it degrades to a warning.
+ */
+ BEGIN TRY
+ EXECUTE sys.sp_executesql
+ @mem_sql;
+ END TRY
+ BEGIN CATCH
+ SET @pd_catch_msg = ERROR_MESSAGE();
+ RAISERROR(N'Memory-grant queries skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT;
+ END CATCH;
END
IF @log_to_table = 1
@@ -3539,8 +3615,15 @@ OPTION(MAXDOP 1, RECOMPILE);',
PRINT @insert_sql;
END;
- EXECUTE sys.sp_executesql
- @insert_sql;
+ /* Same plan-fetch TRY/CATCH as the client path above. */
+ BEGIN TRY
+ EXECUTE sys.sp_executesql
+ @insert_sql;
+ END TRY
+ BEGIN CATCH
+ SET @pd_catch_msg = ERROR_MESSAGE();
+ RAISERROR(N'Memory-grant query logging skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT;
+ END CATCH;
END;
END;
@@ -3692,13 +3775,78 @@ OPTION(MAXDOP 1, RECOMPILE);',
);
END;
+ /*
+ The same panel again, but keyed on OTHER-process CPU rather than
+ SQL Server's own. Separate variable, separate result column: a
+ DBA can see "SQL is hot" and "something else on the box is hot"
+ as two distinct answers instead of one masking the other.
+ */
+ SELECT
+ @cpu_utilization_external =
+ x.cpu_utilization
+ FROM
+ (
+ SELECT
+ sample_time =
+ CONVERT
+ (
+ datetime,
+ DATEADD
+ (
+ SECOND,
+ (t.timestamp - osi.ms_ticks) / 1000,
+ SYSDATETIME()
+ )
+ ),
+ sqlserver_cpu_utilization =
+ t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]','integer'),
+ other_process_cpu_utilization =
+ (100 - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]','integer')
+ - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]','integer')),
+ total_cpu_utilization =
+ (100 - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'integer'))
+ FROM sys.dm_os_sys_info AS osi
+ CROSS JOIN
+ (
+ SELECT
+ dorb.timestamp,
+ record =
+ CONVERT(xml, dorb.record)
+ FROM sys.dm_os_ring_buffers AS dorb
+ WHERE dorb.ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR'
+ ) AS t
+ /* Each value forced to a singleton with [1] before the arithmetic; XQuery treats a bare child step as a sequence and rejects '-' on it */
+ WHERE t.record.exist('(Record/SchedulerMonitorEvent/SystemHealth)[(100 - (ProcessUtilization)[1] - (SystemIdle)[1]) >= sql:variable("@cpu_utilization_threshold")]') = 1
+ ORDER BY
+ sample_time DESC
+ FOR XML
+ PATH('cpu_utilization'),
+ TYPE
+ ) AS x (cpu_utilization)
+ OPTION(MAXDOP 1, RECOMPILE);
+
+ IF @cpu_utilization_external IS NULL
+ BEGIN
+ SELECT
+ @cpu_utilization_external =
+ (
+ SELECT
+ N'No significant non-SQL Server CPU usage data available.'
+ FOR XML
+ PATH(N'cpu_utilization'),
+ TYPE
+ );
+ END;
+
IF @log_to_table = 0
BEGIN
SELECT
cpu_details_output =
@cpu_details_output,
cpu_utilization_over_threshold =
- @cpu_utilization;
+ @cpu_utilization,
+ external_cpu_utilization_over_threshold =
+ @cpu_utilization_external;
END;
IF @log_to_table = 1
BEGIN
@@ -3724,6 +3872,17 @@ OPTION(MAXDOP 1, RECOMPILE);',
N'@max_sample_time_out datetime OUTPUT',
@max_sample_time OUTPUT;
+ /*
+ UNION both CPU signals into the same insert. The event table
+ already carries other_process_cpu_utilization and
+ total_cpu_utilization, but the SQL-CPU XML only holds samples
+ that tripped SQL's own threshold — so a sample where an
+ external process pegged the box while SQL sat idle was never
+ logged. Adding the external XML captures exactly those; UNION
+ (not UNION ALL) collapses samples that tripped both. The
+ no-data placeholder XML has no sample_time node, so its
+ exist() filter is false and it contributes nothing.
+ */
SET @insert_sql = N'
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
INSERT INTO ' + @log_table_cpu_events + N'
@@ -3739,6 +3898,16 @@ OPTION(MAXDOP 1, RECOMPILE);',
other_process_cpu_utilization = event.value(''(./other_process_cpu_utilization)[1]'', ''integer''),
total_cpu_utilization = event.value(''(./total_cpu_utilization)[1]'', ''integer'')
FROM @cpu_utilization.nodes(''/cpu_utilization'') AS cpu(event)
+ WHERE event.exist(''(./sample_time)[. > sql:variable("@max_sample_time")]'') = 1
+
+ UNION
+
+ SELECT
+ sample_time = event.value(''(./sample_time)[1]'', ''datetime''),
+ sqlserver_cpu_utilization = event.value(''(./sqlserver_cpu_utilization)[1]'', ''integer''),
+ other_process_cpu_utilization = event.value(''(./other_process_cpu_utilization)[1]'', ''integer''),
+ total_cpu_utilization = event.value(''(./total_cpu_utilization)[1]'', ''integer'')
+ FROM @cpu_utilization_external.nodes(''/cpu_utilization'') AS cpu(event)
WHERE event.exist(''(./sample_time)[. > sql:variable("@max_sample_time")]'') = 1;';
IF @debug = 1
@@ -3749,8 +3918,10 @@ OPTION(MAXDOP 1, RECOMPILE);',
EXECUTE sys.sp_executesql
@insert_sql,
N'@cpu_utilization xml,
+ @cpu_utilization_external xml,
@max_sample_time datetime',
@cpu_utilization,
+ @cpu_utilization_external,
@max_sample_time;
END;
@@ -3790,7 +3961,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
total_active_parallel_thread_count =
MAX(wg.active_parallel_thread_count),
avg_runnable_tasks_count =
- AVG(dos.runnable_tasks_count),
+ AVG(1. * dos.runnable_tasks_count),
high_runnable_percent =
MAX(ISNULL(r.high_runnable_percent, 0))
FROM sys.dm_os_schedulers AS dos
@@ -3829,8 +4000,8 @@ OPTION(MAXDOP 1, RECOMPILE);',
(
x.runnable /
(1. * NULLIF(x.total, 0))
- )
- ) * 100.
+ ) * 100.
+ )
FROM
(
SELECT
@@ -4188,8 +4359,15 @@ OPTION(MAXDOP 1, RECOMPILE);',
IF @log_to_table = 0
BEGIN
- EXECUTE sys.sp_executesql
- @cpu_sql;
+ /* Same plan-fetch TRY/CATCH as the memory-grants path. */
+ BEGIN TRY
+ EXECUTE sys.sp_executesql
+ @cpu_sql;
+ END TRY
+ BEGIN CATCH
+ SET @pd_catch_msg = ERROR_MESSAGE();
+ RAISERROR(N'CPU queries skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT;
+ END CATCH;
END;
IF @log_to_table = 1
@@ -4254,8 +4432,15 @@ OPTION(MAXDOP 1, RECOMPILE);',
PRINT @insert_sql;
END;
- EXECUTE sys.sp_executesql
- @insert_sql;
+ /* Same plan-fetch TRY/CATCH as the client path above. */
+ BEGIN TRY
+ EXECUTE sys.sp_executesql
+ @insert_sql;
+ END TRY
+ BEGIN CATCH
+ SET @pd_catch_msg = ERROR_MESSAGE();
+ RAISERROR(N'CPU query logging skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT;
+ END CATCH;
END;
END; /*End not skipping queries*/
END; /*End CPU checks*/
From 5524eea87931737e7e6f74828051f003a9bc5a4a Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 14 Jul 2026 15:07:33 -0400
Subject: [PATCH 11/60] sp_PerfCheck: fix checks that misfire, crash, or give
impossible advice
This procedure emits server health/perf findings a DBA acts on, so a
check that misses a real problem, fires falsely, or recommends the
impossible is worse than no check. A multi-agent hunt reproduced each
of these live; a second adversarial review of the fixes then caught
four more defects in the same code, corrected here too.
Silent false negatives:
- Check 7011 (Query Store state) filtered actual_state IN (0, 3), which
excludes READ_ONLY (state 1). A Query Store that fills to its max size
auto-flips to READ_ONLY and stops capturing new data - its single most
common operational failure - and no Query Store check fired for it, so
the report came back clean. Now IN (0, 1, 3); Availability Group
secondaries and intentional read-only are still excluded by the
existing predicates.
- @database_name pointing at a database that does not exist (a typo) or
is not ONLINE (mid-restore, RECOVERY_PENDING, SUSPECT, a log-shipping
standby) matched no rows and produced no database findings and no
error, reading as a clean report on a database that was never looked
at. It is now validated up front, before any check runs, and fails
loudly.
Crashes and false positives:
- wait_time_percent_of_uptime was decimal(6, 2). It is cumulative wait
time across all schedulers as a percentage of wall-clock uptime, so on
a many-core server it runs to thousands of percent, overflowed 9999.99,
and threw out of the whole procedure - no findings at all - on exactly
the busy servers that most need checking. Widened to decimal(38, 2).
- The SQL Server 2025 database-scoped-configuration checks (7020) used
wrong configuration_ids: 40/41 are READABLE_SECONDARY_TEMPORARY_STATS_*
(no matching evaluation, so flagged non-default on every 2025 database),
43 does not exist, and the three options actually evaluated
(OPTIMIZED_SP_EXECUTESQL, FULLTEXT_INDEX_VERSION,
OPTIONAL_PARAMETER_OPTIMIZATION) live at 42/44/47 and were never
pulled. Corrected against the live 2025 catalog; those ids do not exist
on 2016-2022 so no earlier version is affected. PREVIEW_FEATURES was
dropped (its coded default disagreed with the shipped default).
- DOP_FEEDBACK's default flipped from 0 (2019-2022) to 1 (2025), so the
fixed default flagged it non-default on every 2025 database. The
default is now version-gated.
Impossible advice:
- Check 7009 recommended enabling Accelerated Database Recovery on SQL
Server 2016/2017, where ADR does not exist. The absent column is
hardcoded to 0 there, so the check fired for any database with snapshot
isolation or RCSI - most of them. Now gated on the column existing.
- The Lock Pages in Memory finding fires at 32GB of RAM but its text said
the recommendation was for more than 64GB. Text aligned to 32GB.
Also: check 7011 now renders a clear message for an explicitly read-only
database instead of "Unknown reason code: 1".
Verified on SQL Server 2016 through 2025: a full/read-only Query Store is
now flagged; a bad or offline @database_name errors clearly instead of
returning a false clean report; the wait-percent overflow is gone; 7020
produces no false positives on 2025 and still catches genuinely
non-default settings; and 7009 no longer recommends ADR where it does not
exist.
Co-Authored-By: Claude Fable 5
---
sp_PerfCheck/sp_PerfCheck.sql | 102 +++++++++++++++++++++++++++++-----
1 file changed, 89 insertions(+), 13 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index 80c250a2..bd3c6296 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -391,6 +391,46 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
is_aws_rds = @aws_rds;
END;
+ /*
+ Validate @database_name up front, before any check runs. If the
+ caller named a database that does not exist (a typo), or one that is
+ not ONLINE (mid-restore, RECOVERY_PENDING, SUSPECT, a log-shipping
+ standby), the per-database checks match no rows and the report comes
+ back with no database findings and no error - reading as "this
+ database is clean" when it was never looked at. Fail loudly here
+ rather than running the whole server sweep and then returning empty.
+ Azure SQL DB is exempt: it always analyzes the current database via
+ DB_ID() and ignores the parameter.
+ */
+ IF @database_name IS NOT NULL
+ AND @azure_sql_db = 0
+ BEGIN
+ IF NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.databases AS d
+ WHERE d.name = @database_name
+ )
+ BEGIN
+ RAISERROR(N'The database %s does not exist on this server. Check the name and try again.', 11, 1, @database_name) WITH NOWAIT;
+ RETURN;
+ END;
+
+ IF NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.databases AS d
+ WHERE d.name = @database_name
+ AND d.state = 0 /* ONLINE */
+ )
+ BEGIN
+ RAISERROR(N'The database %s exists but is not ONLINE, so its configuration cannot be analyzed. Run without @database_name for the server-level checks.', 11, 1, @database_name) WITH NOWAIT;
+ RETURN;
+ END;
+ END;
+
/*
Create a table for stuff I care about from sys.databases
With comments on what we want to check
@@ -585,7 +625,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
avg_wait_ms AS (wait_time_ms / NULLIF(waiting_tasks_count, 0)),
percentage decimal(5, 2) NOT NULL,
signal_wait_time_ms bigint NOT NULL,
- wait_time_percent_of_uptime decimal(6, 2) NULL,
+ /*
+ decimal(38, 2), not decimal(6, 2). This is cumulative wait time
+ across all schedulers as a percentage of wall-clock uptime, so
+ on a many-core server it routinely runs to several hundred or
+ several thousand percent (64 cores fully waiting is ~6400%).
+ decimal(6, 2) caps at 9999.99, and the overflow THREW out of the
+ whole procedure - no health findings at all - on exactly the
+ busy, high-core servers that most need checking.
+ */
+ wait_time_percent_of_uptime decimal(38, 2) NULL,
category nvarchar(50) NOT NULL
);
@@ -1172,7 +1221,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
details =
N'SQL Server is not using locked pages in memory (LPIM). This can lead to Windows ' +
N'taking memory away from SQL Server under memory pressure, causing performance issues. ' +
- N'For production SQL Servers with more than 64GB of memory, LPIM should be enabled.',
+ N'For production SQL Servers with 32GB of memory or more, LPIM should be enabled.',
url = N'https://erikdarling.com/sp_perfcheck/#LPIM'
FROM sys.dm_os_sys_info AS osi
WHERE osi.sql_memory_model_desc = N'CONVENTIONAL' /* Conventional means not using LPIM */
@@ -4441,6 +4490,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHEN 0 THEN N''No specific reason identified.''
WHEN 2 THEN N''Database is in single user mode.''
WHEN 4 THEN N''Database is in emergency mode.''
+ WHEN 1 THEN N''Database is in read-only mode.''
WHEN 8 THEN N''Database is an Availability Group secondary.''
WHEN 65536 THEN N''Query Store has reached maximum size: '' +
CONVERT(nvarchar(20), qso.current_storage_size_mb) +
@@ -4457,7 +4507,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE qso.desired_state <> 0 /* Not intentionally OFF */
AND qso.readonly_reason <> 8 /* Ignore AG secondaries */
AND qso.desired_state <> qso.actual_state /* States don''t match */
- AND qso.actual_state IN (0, 3); /* Either OFF or READ_ONLY when it shouldn''t be */';
+ AND qso.actual_state IN (0, 1, 3); /* OFF(0), READ_ONLY(1), or ERROR(3) when it should be READ_WRITE - the comment used to say READ_ONLY but the list omitted state 1, so a Query Store auto-flipped to READ_ONLY because it filled up (readonly_reason 65536) never got flagged, its single most common failure. AG secondaries (readonly_reason 8) and intentional READ_ONLY (desired = actual) are already excluded above. */';
IF @debug = 1
BEGIN
@@ -4595,11 +4645,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(@current_database_id, @current_database_name, 35, N''OPTIMIZED_PLAN_FORCING'', NULL, NULL, 1),
(@current_database_id, @current_database_name, 37, N''DOP_FEEDBACK'', NULL, NULL, 1),
(@current_database_id, @current_database_name, 39, N''FORCE_SHOWPLAN_RUNTIME_PARAMETER_COLLECTION'', NULL, NULL, 1),
- /* SQL Server 2025 options - IDs to be verified against actual SQL Server 2025 instance */
- (@current_database_id, @current_database_name, 40, N''PREVIEW_FEATURES'', NULL, NULL, 1),
- (@current_database_id, @current_database_name, 41, N''OPTIMIZED_SP_EXECUTESQL'', NULL, NULL, 1),
- (@current_database_id, @current_database_name, 42, N''FULLTEXT_INDEX_VERSION'', NULL, NULL, 1),
- (@current_database_id, @current_database_name, 43, N''OPTIONAL_PARAMETER_OPTIMIZATION'', NULL, NULL, 1);
+ /* SQL Server 2025 options, real configuration_ids from the catalog (17.0). */
+ (@current_database_id, @current_database_name, 42, N''OPTIMIZED_SP_EXECUTESQL'', NULL, NULL, 1),
+ (@current_database_id, @current_database_name, 44, N''FULLTEXT_INDEX_VERSION'', NULL, NULL, 1),
+ (@current_database_id, @current_database_name, 47, N''OPTIONAL_PARAMETER_OPTIMIZATION'', NULL, NULL, 1);
/* Get actual non-default settings */
INSERT INTO
@@ -4642,10 +4691,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHEN sc.name = N''MEMORY_GRANT_FEEDBACK_PERSISTENCE'' AND CONVERT(integer, sc.value) = 1 THEN 1
WHEN sc.name = N''MEMORY_GRANT_FEEDBACK_PERCENTILE_GRANT'' AND CONVERT(integer, sc.value) = 1 THEN 1
WHEN sc.name = N''OPTIMIZED_PLAN_FORCING'' AND CONVERT(integer, sc.value) = 1 THEN 1
- WHEN sc.name = N''DOP_FEEDBACK'' AND CONVERT(integer, sc.value) = 0 THEN 1
+ /* DOP_FEEDBACK default flipped from 0 (2019-2022) to 1 (2025+); inject the version-appropriate default so it is not flagged non-default on every 2025 database. */
+ WHEN sc.name = N''DOP_FEEDBACK'' AND CONVERT(integer, sc.value) = ' + CASE WHEN @product_version_major >= 17 THEN N'1' ELSE N'0' END + N' THEN 1
WHEN sc.name = N''FORCE_SHOWPLAN_RUNTIME_PARAMETER_COLLECTION'' AND CONVERT(integer, sc.value) = 0 THEN 1
- /* SQL Server 2025 options */
- WHEN sc.name = N''PREVIEW_FEATURES'' AND CONVERT(integer, sc.value) = 0 THEN 1
+ /* SQL Server 2025 options. PREVIEW_FEATURES was dropped: its real default is 1, not 0, and it is not evaluated here. These three defaults match the live 2025 catalog (0, 2, 1). */
WHEN sc.name = N''OPTIMIZED_SP_EXECUTESQL'' AND CONVERT(integer, sc.value) = 0 THEN 1
WHEN sc.name = N''FULLTEXT_INDEX_VERSION'' AND CONVERT(integer, sc.value) = 2 THEN 1
WHEN sc.name = N''OPTIONAL_PARAMETER_OPTIMIZATION'' AND CONVERT(integer, sc.value) = 1 THEN 1
@@ -4657,7 +4706,19 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1, 2, 3, 4, 7, 8, 9,
10, 13, 16, 17, 18, 19, 20, 24,
27, 28, 31, 33, 34, 35, 37, 39,
- 40, 41, 42, 43 /* SQL Server 2025 options */
+ /*
+ SQL Server 2025 options, with the real
+ configuration_ids from the catalog. The IDs used
+ to be 40, 41, 42, 43: 40/41 are actually
+ READABLE_SECONDARY_TEMPORARY_STATS_AUTO_CREATE and
+ _UPDATE, which have no entry in the CASE above, so
+ they were pulled and flagged non-default on every
+ 2025 database even at their default of 1; 43 does
+ not exist; and the three options the CASE really
+ evaluates (42, 44, 47) were never pulled, so they
+ were silently never checked.
+ */
+ 42, 44, 47
);
END;';
@@ -4789,7 +4850,21 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE d.database_id = @current_database_id
AND d.delayed_durability_desc <> N'DISABLED';
- /* Check if the database has accelerated database recovery disabled with SI/RCSI enabled */
+ /*
+ Check if the database has accelerated database recovery disabled
+ with SI/RCSI enabled.
+
+ Gated on the column actually existing. Accelerated Database
+ Recovery arrived in SQL Server 2019; on 2016/2017 the
+ is_accelerated_database_recovery_on column does not exist, so the
+ #databases builder hardcodes it to 0 for every database. Without
+ this gate the check's "= 0" predicate was ALWAYS true and it
+ recommended enabling ADR - a feature those versions do not have -
+ for any database with snapshot isolation or RCSI on, which is
+ most of them. Advice impossible to act on.
+ */
+ IF @has_is_accelerated_database_recovery = 1
+ BEGIN
INSERT INTO
#results
(
@@ -4819,6 +4894,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
d.snapshot_isolation_state_desc = N'ON'
OR d.is_read_committed_snapshot_on = 1
);
+ END;
/* Check if ledger is enabled */
INSERT INTO
From ad1a002c5ff25c8a4a824011ab098ab5bcf622a2 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 14 Jul 2026 18:01:05 -0400
Subject: [PATCH 12/60] sp_QueryReproBuilder: fix five correctness bugs found
in bug sweep
1. Ignore-list shred referenced an ambiguous column x inside the generated
dynamic SQL (a.x is the whole .. document, b.x is each shredded
node). It raised error 209 at compile time, so @ignore_plan_ids and
@ignore_query_ids silently filtered nothing. Qualify as b.x.value.
2. The "fill in parameters from query text" step split the auto-parameterized
prefix on every comma, tearing scaled types (numeric(10,2)) in half and
blowing up LEFT with error 537, aborting the whole procedure for any query
with a scaled-type parameter. Split on the ',@' parameter boundary instead.
3. @procedure_name_quoted was bound to sp_executesql as sysname while its
variable is nvarchar(1024). A quoted three-part name over 128 chars was
truncated, OBJECT_ID failed, and a valid procedure was falsely reported as
absent. Bind as nvarchar(1024), matching the four other bindings.
4. In @query_plan_xml mode, a document with no StmtSimple node produced a
silent blank repro. Raise an informational error and return instead.
5. Schema parsing only matched fully bracketed [schema].[proc]; an unbracketed
dbo.proc fell through and was QUOTENAME'd whole into a single mangled
[dbo.proc] identifier. Use PARSENAME, which handles both forms, guarded to
exactly two parts so a stray three-part name doesn't drop its database.
Verified: compiles clean on SQL 2016-2025; unit and end-to-end tests confirm
each fix on all five versions.
Co-Authored-By: Claude Opus 4.8
---
sp_QueryReproBuilder/sp_QueryReproBuilder.sql | 60 +++++++++++++++----
1 file changed, 50 insertions(+), 10 deletions(-)
diff --git a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
index 2710fdfc..089b3402 100644
--- a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
+++ b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
@@ -573,13 +573,19 @@ SELECT
NULLIF(@query_text_search_not, '');
/*
-Parse schema from procedure name if provided in schema.procedure format
+Parse schema from procedure name if provided in schema.procedure format.
+PARSENAME copes with bracketed ([dbo].[proc]) and unbracketed (dbo.proc)
+alike; the old LIKE '[[]%].[[]%]' pattern only matched the fully bracketed
+form, so an unbracketed dbo.proc fell through and was later QUOTENAME'd whole
+into a single mangled [dbo.proc] identifier that could never resolve.
+Only split when the caller didn't pass @procedure_schema separately, the name
+has a schema part, and it's exactly two parts (part 3 NULL) so a stray
+three-part name doesn't silently drop its database component here.
*/
-IF
-(
- @procedure_name LIKE N'[[]%].[[]%]'
- AND @procedure_schema IS NULL
-)
+IF @procedure_name IS NOT NULL
+AND @procedure_schema IS NULL
+AND PARSENAME(@procedure_name, 2) IS NOT NULL
+AND PARSENAME(@procedure_name, 3) IS NULL
BEGIN
SELECT
@procedure_schema = PARSENAME(@procedure_name, 2),
@@ -745,7 +751,7 @@ OPTION(RECOMPILE);' + @nc10;
EXECUTE sys.sp_executesql
@sql,
N'@procedure_exists bit OUTPUT,
- @procedure_name_quoted sysname',
+ @procedure_name_quoted nvarchar(1024)', /* nvarchar(1024), matching the variable's own declaration; binding it as sysname truncated the quoted 3-part name to 128 chars, so OBJECT_ID failed and a valid procedure was falsely rejected */
@procedure_exists OUTPUT,
@procedure_name_quoted;
@@ -1204,6 +1210,20 @@ BEGIN
N'nvarchar(max)'
);
+ /*
+ No StmtSimple node means this isn't a ShowPlanXML document we can build a
+ repro from (wrong XML entirely, or a plan shape with no simple statement,
+ e.g. cursor-only). Stop here rather than silently seeding a blank query and
+ handing the user an empty repro.
+ */
+ IF @synthetic_query_text IS NULL
+ BEGIN
+ RAISERROR('The supplied @query_plan_xml has no StmtSimple statement text
+Pass a valid ShowPlanXML document (the output of an actual/estimated execution plan for a single statement)',
+ 10, 1) WITH NOWAIT;
+ RETURN;
+ END;
+
INSERT
#query_store_plan
(
@@ -2116,6 +2136,12 @@ BEGIN
SELECT
@sql = @isolation_level;
+ /*
+ The shredded value must be qualified b.x, not x: inside the generated query,
+ a.x is the whole .. document and b.x is each per-node from the
+ CROSS APPLY. An unqualified x.value was ambiguous (error 209), so this ignore
+ list silently failed to compile and never filtered anything.
+ */
SELECT
@sql += N'
INSERT
@@ -2131,7 +2157,7 @@ BEGIN
(
SELECT
plan_id =
- x.value
+ b.x.value
(
''(./text())[1]'',
''bigint''
@@ -2175,6 +2201,10 @@ BEGIN
SELECT
@sql = @isolation_level;
+ /*
+ Same b.x qualification as the @ignore_plan_ids block above: b.x is the
+ per-node from the CROSS APPLY, and leaving it unqualified was error 209.
+ */
SELECT
@sql += N'
INSERT
@@ -2190,7 +2220,7 @@ BEGIN
(
SELECT
query_id =
- x.value
+ b.x.value
(
''(./text())[1]'',
''bigint''
@@ -4285,8 +4315,18 @@ CROSS APPLY
param_xml =
TRY_CAST
(
+ /*
+ Split on ',@' (the parameter boundary), not every comma.
+ A scaled type carries its own comma (numeric(10,2),
+ decimal(38,6)), always followed by a digit; the separator
+ between parameters is always a comma immediately followed by
+ the next parameter's @name. Splitting on every comma tore
+ numeric(10,2) in half, and the malformed fragment blew up
+ LEFT with error 537, aborting the whole procedure for any
+ query with a scaled-type parameter.
+ */
N'' +
- REPLACE(prefix.param_prefix, N',', N'
') +
+ REPLACE(prefix.param_prefix, N',@', N'
@') +
N'
' AS xml
)
) AS px
From cc76d4d39997a2ab5797edafd472938de6ea22bd Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 14 Jul 2026 23:52:59 -0400
Subject: [PATCH 13/60] sp_QuickieStore: fix twelve correctness bugs found in
bug sweep
Object names:
1. The schema.procedure overload only matched a fully-bracketed [s].[p]; an
unbracketed dbo.proc fell through and was QUOTENAME'd whole into a mangled
[dbo.proc] that never resolved. Use PARSENAME (handles both forms), run it
before the @database_name default, and adopt a three-part paste's own
database instead of dropping it (wrong-database lookup) or failing.
2. @procedure_name_quoted was bound to sp_executesql as sysname (128 chars) at
three sites though its variable is nvarchar(1024); a long quoted three-part
name truncated, OBJECT_ID failed, and a real procedure was reported absent.
The nine @log_table_* name variables had the same sysname truncation.
Crashes:
3. The reusable @string_split_ints/@string_split_strings lacked the empty-element
filter the @include_databases splitter has, so a leading/trailing/doubled
comma produced a NULL row that aborted the INSERT into NOT NULL key columns
with Msg 515. Added WHERE ids.ids IS NOT NULL.
4. @include_databases/@exclude_databases built XML by raw concatenation without
escaping & < >, so a legal database name like R&D threw Msg 9411 and aborted
the procedure. Escape entities before wrapping in tags.
Silent wrong results:
5/11. Three instance-level validations (hints/feedback/variants pre-2022, wait
stats pre-2017, invalid wait category) only returned when
@get_all_databases = 0, so in all-databases mode they warned then produced
unfiltered results or NULL'd the wait SQL to zero rows. They now halt in
both modes; the per-database lenient checks are unchanged.
6. @sort_order varchar(20) truncated the documented 'average ' forms
before normalization, silently reverting to cpu. Widened to varchar(50).
7. The regression metric for 'executions' averaged the bigint count_executions
before the float cast, truncating the per-interval mean. Cast to float
inside AVG (and the same for the waits fallback), at both metric sites.
8. @find_high_impact weekend bucketing used DATEPART(WEEKDAY,...) IN (1,7),
correct only under @@DATEFIRST = 7; on DATEFIRST = 1 it mislabeled Monday as
Weekend and Saturday as a weekday. Use DATEDIFF(DAY, 0, local) % 7 IN (5, 6),
which is DATEFIRST-independent, at both the SELECT and GROUP BY.
10. @find_high_impact avg_rows was bigint, so total_rows/total_executions
integer-divided and truncated (3/4 -> 0) unlike every decimal sibling.
Made it decimal(38,6).
12. The primary-window percentage was CONVERT(integer,...) then tested '> 50',
so a real 50.1-50.99% majority truncated to 50 and was mislabeled 'Spread'.
Use decimal(5,1).
Logging:
G1. With @log_to_table = 1 and the default @expert_mode = 0, only 1 of 9 log
tables was populated; the other 8 sat behind expert-mode output gates. Added
OR @log_to_table = 1 to the seven gates so all tables capture when logging;
the display/empty paths are @log_to_table = 0 guarded so no result sets leak.
Verified: compiles clean on SQL 2016-2025; unit tests, a six-scenario live
battery, and an end-to-end @log_to_table run confirm each fix; independent
review clean. Also verified (no fix needed): the log INSERT/SELECT/CREATE column
lists align on every version, and no sp_executesql references an undeclared
variable.
Co-Authored-By: Claude Opus 4.8
---
sp_QuickieStore/sp_QuickieStore.sql | 196 ++++++++++++++++++----------
1 file changed, 125 insertions(+), 71 deletions(-)
diff --git a/sp_QuickieStore/sp_QuickieStore.sql b/sp_QuickieStore/sp_QuickieStore.sql
index 7fb16fb5..558e99f6 100644
--- a/sp_QuickieStore/sp_QuickieStore.sql
+++ b/sp_QuickieStore/sp_QuickieStore.sql
@@ -55,7 +55,7 @@ ALTER PROCEDURE
dbo.sp_QuickieStore
(
@database_name sysname = NULL, /*the name of the database you want to look at query store in*/
- @sort_order varchar(20) = 'cpu', /*the runtime metric you want to prioritize results by*/
+ @sort_order varchar(50) = 'cpu', /*the runtime metric you want to prioritize results by; varchar(50) so the documented 'average '/'avg ' prefix forms (e.g. 'average buffer latches waits') aren't truncated before normalization and silently fall back to cpu*/
@top bigint = 10, /*the number of queries you want to pull back*/
@start_date datetimeoffset(7) = NULL, /*the begin date of your search, will be converted to UTC internally*/
@end_date datetimeoffset(7) = NULL, /*the end date of your search, will be converted to UTC internally*/
@@ -2005,6 +2005,37 @@ FROM
)
WHERE v.parameter_value IS NOT NULL;
+/*
+Attempt at overloading procedure name so it can accept a schema.procedure or
+db.schema.procedure pasted from results from other executions of sp_QuickieStore.
+PARSENAME copes with bracketed ([dbo].[proc]) and unbracketed (dbo.proc) forms
+alike; the old LIKE '[[]%].[[]%]' pattern only matched the fully bracketed
+two-part form, so an unbracketed dbo.proc fell through and was later QUOTENAME'd
+whole into a single mangled [dbo.proc] identifier that could never resolve.
+This runs before the @database_name default below so a three-part paste can
+supply its own database.
+*/
+IF @procedure_name IS NOT NULL
+AND @procedure_schema IS NULL
+AND PARSENAME(@procedure_name, 2) IS NOT NULL
+BEGIN
+ /*
+ A three-part db.schema.proc paste: adopt its database when the caller didn't
+ pass @database_name, rather than dropping it (which looked the procedure up
+ in the wrong database) or refusing to split it (a false 'not found').
+ */
+ IF PARSENAME(@procedure_name, 3) IS NOT NULL
+ AND @database_name IS NULL
+ BEGIN
+ SELECT
+ @database_name = PARSENAME(@procedure_name, 3);
+ END;
+
+ SELECT
+ @procedure_schema = PARSENAME(@procedure_name, 2),
+ @procedure_name = PARSENAME(@procedure_name, 1);
+END;
+
/*
Try to be helpful by subbing in a database name if null
*/
@@ -2032,22 +2063,6 @@ BEGIN
DB_NAME();
END;
-/*
-Attempt at overloading procedure name so it can
-accept a [schema].[procedure] pasted from results
-from other executions of sp_QuickieStore
-*/
-IF
-(
- @procedure_name LIKE N'[[]%].[[]%]'
- AND @procedure_schema IS NULL
-)
-BEGIN
- SELECT
- @procedure_schema = PARSENAME(@procedure_name, 2),
- @procedure_name = PARSENAME(@procedure_name, 1);
-END;
-
/*
Variables for the variable gods
*/
@@ -2112,15 +2127,16 @@ DECLARE
@temp_target_table nvarchar(100),
@exist_or_not_exist nvarchar(20),
/*Log to table stuff*/
- @log_table_runtime_stats sysname,
- @log_table_compilation_stats sysname,
- @log_table_resource_stats sysname,
- @log_table_wait_stats_by_query sysname,
- @log_table_wait_stats_total sysname,
- @log_table_plan_feedback sysname,
- @log_table_query_hints sysname,
- @log_table_query_variants sysname,
- @log_table_query_store_options sysname,
+ /* nvarchar(1024), not sysname: each holds a full 3-part quoted name (@log_database_schema + QUOTENAME(prefix_suffix)); sysname(128) truncated it, cutting the closing bracket on a long log database/schema name and breaking the generated CREATE/INSERT/DELETE. Matches @log_database_schema's own width. */
+ @log_table_runtime_stats nvarchar(1024),
+ @log_table_compilation_stats nvarchar(1024),
+ @log_table_resource_stats nvarchar(1024),
+ @log_table_wait_stats_by_query nvarchar(1024),
+ @log_table_wait_stats_total nvarchar(1024),
+ @log_table_plan_feedback nvarchar(1024),
+ @log_table_query_hints nvarchar(1024),
+ @log_table_query_variants nvarchar(1024),
+ @log_table_query_store_options nvarchar(1024),
@cleanup_date datetime2(7),
@create_sql nvarchar(max),
@insert_sql nvarchar(max),
@@ -2950,10 +2966,11 @@ BEGIN
x = CONVERT
(
xml,
+ /* escape &, <, > before building the XML (innermost, so the tags we add stay literal); a legal database name like R&D otherwise makes CONVERT(xml, ...) throw error 9411 and aborts the procedure */
N'' +
REPLACE
(
- @include_databases,
+ REPLACE(REPLACE(REPLACE(@include_databases, N'&', N'&'), N'<', N'<'), N'>', N'>'),
N',',
N''
) +
@@ -2984,10 +3001,11 @@ BEGIN
x = CONVERT
(
xml,
+ /* escape &, <, > before building the XML (innermost, so the tags we add stay literal); a legal database name like R&D otherwise makes CONVERT(xml, ...) throw error 9411 and aborts the procedure */
N'' +
REPLACE
(
- @exclude_databases,
+ REPLACE(REPLACE(REPLACE(@exclude_databases, N'&', N'&'), N'<', N'<'), N'>', N'>'),
N',',
N''
) +
@@ -3445,6 +3463,13 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;',
@sql_2022_views = 0,
@ags_present = 0,
@current_table = N'',
+ /*
+ Both reusable list splitters below carry WHERE ids.ids IS NOT NULL: a
+ leading, trailing, or doubled comma in a caller's list (e.g. '123,456,')
+ produces an empty node whose value is NULL, which otherwise flows
+ into the NOT NULL primary-key id/hash/handle columns and aborts the whole
+ procedure with Msg 515. The @include_databases splitter already guarded this.
+ */
@string_split_ints = N'
SELECT DISTINCT
ids =
@@ -3488,6 +3513,7 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;',
) AS ids
CROSS APPLY ids.nodes(''x'') AS x (x)
) AS ids
+ WHERE ids.ids IS NOT NULL
OPTION(RECOMPILE);
',
@string_split_strings = N'
@@ -3533,6 +3559,7 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;',
) AS ids
CROSS APPLY ids.nodes(''x'') AS x (x)
) AS ids
+ WHERE ids.ids IS NOT NULL
OPTION(RECOMPILE);
',
@troubleshoot_insert = N'
@@ -4191,7 +4218,7 @@ OPTION(RECOMPILE);' + @nc10;
EXECUTE sys.sp_executesql
@sql,
N'@procedure_exists bit OUTPUT,
- @procedure_name_quoted sysname',
+ @procedure_name_quoted nvarchar(1024)', /* nvarchar(1024) to match the variable's declaration; sysname truncated a quoted 3-part name over 128 chars, so OBJECT_ID failed and a valid procedure was falsely reported absent */
@procedure_exists OUTPUT,
@procedure_name_quoted;
@@ -4255,7 +4282,7 @@ OPTION(RECOMPILE);' + @nc10;
EXECUTE sys.sp_executesql
@sql,
N'@procedure_exists bit OUTPUT,
- @procedure_name_quoted sysname',
+ @procedure_name_quoted nvarchar(1024)', /* nvarchar(1024) to match the variable's declaration; sysname truncated a quoted 3-part name over 128 chars, so OBJECT_ID failed and a valid procedure was falsely reported absent */
@procedure_exists OUTPUT,
@procedure_name_quoted;
@@ -4379,16 +4406,19 @@ IF
BEGIN
RAISERROR('Query Store hints, feedback, and variants are not available prior to SQL Server 2022', 10, 1) WITH NOWAIT;
- IF @get_all_databases = 0
+ /*
+ Instance-level condition: these views are missing for EVERY database on a
+ pre-2022 instance, so halt in both single- and all-databases mode. The old
+ IF @get_all_databases = 0 guard let all-databases mode fall through and
+ return fully unfiltered results after only a level-10 warning.
+ */
+ IF @debug = 1
BEGIN
- IF @debug = 1
- BEGIN
- GOTO DEBUG;
- END;
- ELSE
- BEGIN
- RETURN;
- END;
+ GOTO DEBUG;
+ END;
+ ELSE
+ BEGIN
+ RETURN;
END;
END;
@@ -4403,16 +4433,19 @@ AND @new = 0
BEGIN
RAISERROR('Query Store wait stats are not available prior to SQL Server 2017', 10, 1) WITH NOWAIT;
- IF @get_all_databases = 0
+ /*
+ Instance-level condition (@new is a per-instance version flag): wait stats
+ are missing for EVERY database on a pre-2017 instance, so halt in both
+ single- and all-databases mode rather than falling through in all-databases
+ mode and ignoring the @wait_filter the caller asked for.
+ */
+ IF @debug = 1
BEGIN
- IF @debug = 1
- BEGIN
- GOTO DEBUG;
- END;
- ELSE
- BEGIN
- RETURN;
- END;
+ GOTO DEBUG;
+ END;
+ ELSE
+ BEGIN
+ RETURN;
END;
END;
@@ -4444,16 +4477,19 @@ AND @wait_filter NOT IN
BEGIN
RAISERROR('The wait category (%s) you chose is invalid', 10, 1, @wait_filter) WITH NOWAIT;
- IF @get_all_databases = 0
+ /*
+ An invalid wait category is invalid for every database, so halt in both
+ single- and all-databases mode. Falling through in all-databases mode let
+ the invalid value reach the ELSE-less routing CASE, which returned NULL,
+ NULL'd the whole dynamic SQL, and silently produced zero rows.
+ */
+ IF @debug = 1
BEGIN
- IF @debug = 1
- BEGIN
- GOTO DEBUG;
- END;
- ELSE
- BEGIN
- RETURN;
- END;
+ GOTO DEBUG;
+ END;
+ ELSE
+ BEGIN
+ RETURN;
END;
END;
@@ -4752,7 +4788,7 @@ BEGIN
total_tempdb_mb decimal(38, 6) NOT NULL DEFAULT (0),
avg_tempdb_mb decimal(38, 6) NULL,
total_rows bigint NOT NULL DEFAULT (0),
- avg_rows bigint NULL
+ avg_rows decimal(38, 6) NULL /* decimal, like every sibling average; bigint here integer-divided total_rows/total_executions and truncated (3/4 -> 0) */
);
CREATE TABLE
@@ -5145,7 +5181,7 @@ SELECT
total_rows =
SUM(ps.total_rows),
avg_rows =
- SUM(ps.total_rows) /
+ CONVERT(decimal(38, 6), SUM(ps.total_rows)) /
NULLIF(SUM(ps.total_executions), 0)
FROM #hi_plan_stats AS ps
JOIN ' + @database_name_quoted + N'.sys.query_store_plan AS qsp
@@ -5526,17 +5562,26 @@ OPTION(RECOMPILE);' + @nc10;
SET STATISTICS XML ON;
END;
+ /*
+ Weekend detection uses DATEDIFF(DAY, 0, local) % 7 IN (5, 6), which is
+ independent of @@DATEFIRST and language: SQL Server's day 0 (1900-01-01)
+ is a Monday, so the modulo is 0=Mon..5=Sat..6=Sun everywhere. The old
+ DATEPART(WEEKDAY, ...) IN (1, 7) only meant Sat+Sun under @@DATEFIRST = 7;
+ on a DATEFIRST = 1 server it mislabeled Monday as Weekend and Saturday as
+ a weekday. The SELECT and GROUP BY expressions below must stay identical.
+ */
SELECT
@sql += N'
SELECT
sq.query_hash,
time_bucket =
CASE
- WHEN DATEPART
+ WHEN DATEDIFF
(
- WEEKDAY,
+ DAY,
+ 0,
DATEADD(MINUTE, @utc_minutes_original, CONVERT(datetime, qsrsi.start_time))
- ) IN (1, 7)
+ ) % 7 IN (5, 6)
THEN N''Weekend''
WHEN CONVERT
(
@@ -5565,11 +5610,12 @@ AND qsrsi.start_time < @end_date
GROUP BY
sq.query_hash,
CASE
- WHEN DATEPART
+ WHEN DATEDIFF
(
- WEEKDAY,
+ DAY,
+ 0,
DATEADD(MINUTE, @utc_minutes_original, CONVERT(datetime, qsrsi.start_time))
- ) IN (1, 7)
+ ) % 7 IN (5, 6)
THEN N''Weekend''
WHEN CONVERT
(
@@ -5660,9 +5706,10 @@ OPTION(RECOMPILE);' + @nc10;
tb.query_hash,
tb.time_bucket,
pct =
+ /* decimal, not integer: CONVERT(integer, 50.99) = 50, so a genuine 50.1-50.99% majority was truncated to exactly 50 and failed the '> 50' test below, mislabeling the query 'Spread' */
CONVERT
(
- integer,
+ decimal(5, 1),
100.0 * tb.executions /
SUM(tb.executions) OVER (PARTITION BY tb.query_hash)
)
@@ -7053,7 +7100,7 @@ OPTION(RECOMPILE);' + @nc10;
)
EXECUTE sys.sp_executesql
@sql,
- N'@procedure_name_quoted sysname',
+ N'@procedure_name_quoted nvarchar(1024)', /* nvarchar(1024) to match the variable's declaration; sysname truncated a quoted 3-part name over 128 chars */
@procedure_name_quoted;
IF @troubleshoot_performance = 1
@@ -9023,7 +9070,7 @@ BEGIN
WHEN 'memory' THEN N'SUM(qsrs.avg_query_max_used_memory * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)'
WHEN 'tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END
/* count_executions per interval is meaningful as a plain mean — it''s a count, not an average-of-averages. */
- WHEN 'executions' THEN N'AVG(qsrs.count_executions)'
+ WHEN 'executions' THEN N'AVG(CONVERT(float, qsrs.count_executions))'
WHEN 'rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)'
WHEN 'total cpu' THEN N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)'
WHEN 'total logical reads' THEN N'SUM(qsrs.avg_logical_io_reads * qsrs.count_executions)'
@@ -9034,7 +9081,7 @@ BEGIN
WHEN 'total tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)' END
WHEN 'total rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions)'
/* Waits and the fallback path — waits are per-interval totals so AVG is correct; fallback mirrors cpu path. */
- ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(waits.total_query_wait_time_ms)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END
+ ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(CONVERT(float, waits.total_query_wait_time_ms))' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END
END
+ N'
)
@@ -9139,7 +9186,7 @@ BEGIN
WHEN 'duration' THEN N'SUM(qsrs.avg_duration * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)'
WHEN 'memory' THEN N'SUM(qsrs.avg_query_max_used_memory * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)'
WHEN 'tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END
- WHEN 'executions' THEN N'AVG(qsrs.count_executions)'
+ WHEN 'executions' THEN N'AVG(CONVERT(float, qsrs.count_executions))'
WHEN 'rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)'
WHEN 'total cpu' THEN N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)'
WHEN 'total logical reads' THEN N'SUM(qsrs.avg_logical_io_reads * qsrs.count_executions)'
@@ -9149,7 +9196,7 @@ BEGIN
WHEN 'total memory' THEN N'SUM(qsrs.avg_query_max_used_memory * qsrs.count_executions)'
WHEN 'total tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)' END
WHEN 'total rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions)'
- ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(waits.total_query_wait_time_ms)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END
+ ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(CONVERT(float, waits.total_query_wait_time_ms))' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END
END
+ N'
)
@@ -12467,6 +12514,7 @@ Format numeric values based on @format_output
IF
(
@expert_mode = 1
+ OR @log_to_table = 1 /* logging must populate every secondary table regardless of expert mode; each section's display and empty-message paths below are guarded by @log_to_table = 0, so opening these gates during a logging run emits no result sets */
OR
(
@only_queries_with_hints = 1
@@ -12485,6 +12533,7 @@ BEGIN
*/
IF @expert_mode = 1
OR @only_queries_with_feedback = 1
+ OR @log_to_table = 1
BEGIN
IF EXISTS
(
@@ -12588,6 +12637,7 @@ BEGIN
IF @expert_mode = 1
OR @only_queries_with_hints = 1
+ OR @log_to_table = 1
BEGIN
IF EXISTS
(
@@ -12667,6 +12717,7 @@ BEGIN
IF @expert_mode = 1
OR @only_queries_with_variants = 1
+ OR @log_to_table = 1
BEGIN
IF EXISTS
(
@@ -12810,6 +12861,7 @@ BEGIN
END; /*End 2022 views*/
IF @expert_mode = 1
+ OR @log_to_table = 1
BEGIN
IF EXISTS
(
@@ -13355,6 +13407,7 @@ BEGIN
IF @new = 1
BEGIN
IF @expert_mode = 1
+ OR @log_to_table = 1
BEGIN
IF EXISTS
(
@@ -13735,6 +13788,7 @@ BEGIN
END; /*End wait stats queries*/
IF @expert_mode = 1
+ OR @log_to_table = 1
BEGIN
SELECT
@current_table = 'selecting query store options',
From 7a25296afdce41ba24ec3a526acd36f5df5cb959 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 15 Jul 2026 10:19:05 -0400
Subject: [PATCH 14/60] sp_QuickieStore: fix @workdays weekday
timezone/DATEFIRST bug; clarify search-text docs
#9 (@workdays weekday): the filter computed the weekday from the raw UTC
last_execution_time while the work-hour window was shifted into local-relative
UTC, so a query run on a local weekday morning that lands on the previous UTC
day (far-east time zones, e.g. Sydney Monday 09:30 = Sunday 22:30 UTC) was
dropped as the wrong day. It also only supported @@DATEFIRST 1 and 7, skipping
the weekday filter with a warning on any other server. Now the weekday is
derived from the local execution time (UTC shifted back by -@utc_minutes_difference,
embedded as a literal since it isn't in the shared @parameters list) using
DATEDIFF(DAY, 0, local) % 7 BETWEEN 0 AND 4, which is @@DATEFIRST-independent
(0 = Monday .. 4 = Friday) and works on every server.
G5 (docs): @query_text_search / @query_text_search_not are matched with LIKE, so
_ % [ ] act as wildcards by default. Clarified the parameter comments and the
help output to say so, and corrected the @escape_brackets description (it makes
[, ], and _ literal; % stays a wildcard). Behavior unchanged.
Verified: compiles clean on SQL 2016-2025; a cross-time-zone unit test confirms
the local-weekday predicate (Sydney Monday-morning case now included); @workdays=1
runs clean with no Msg 137 for the embedded offset.
Co-Authored-By: Claude Opus 4.8
---
sp_QuickieStore/sp_QuickieStore.sql | 41 +++++++++++++++--------------
1 file changed, 21 insertions(+), 20 deletions(-)
diff --git a/sp_QuickieStore/sp_QuickieStore.sql b/sp_QuickieStore/sp_QuickieStore.sql
index 558e99f6..aaab9a5a 100644
--- a/sp_QuickieStore/sp_QuickieStore.sql
+++ b/sp_QuickieStore/sp_QuickieStore.sql
@@ -75,9 +75,9 @@ ALTER PROCEDURE
@ignore_query_hashes nvarchar(4000) = NULL, /*a list of query hashes to ignore*/
@ignore_plan_hashes nvarchar(4000) = NULL, /*a list of query plan hashes to ignore*/
@ignore_sql_handles nvarchar(4000) = NULL, /*a list of sql handles to ignore*/
- @query_text_search nvarchar(4000) = NULL, /*query text to search for*/
- @query_text_search_not nvarchar(4000) = NULL, /*query text to exclude*/
- @escape_brackets bit = 0, /*Set this bit to 1 to search for query text containing square brackets (common in .NET Entity Framework and other ORM queries)*/
+ @query_text_search nvarchar(4000) = NULL, /*query text to search for; matched with LIKE, so _ % [ ] are wildcards unless @escape_brackets = 1*/
+ @query_text_search_not nvarchar(4000) = NULL, /*query text to exclude; matched with LIKE, so _ % [ ] are wildcards unless @escape_brackets = 1*/
+ @escape_brackets bit = 0, /*Set this bit to 1 to treat [, ], and _ as literals (not LIKE wildcards) in the query text search; helpful for .NET Entity Framework and other ORM queries. Percent (%) stays a wildcard.*/
@escape_character nchar(1) = N'\', /*Sets the ESCAPE character for special character searches, defaults to the SQL standard backslash (\) character*/
@only_queries_with_hints bit = 0, /*Set this bit to 1 to retrieve only queries with query hints*/
@only_queries_with_feedback bit = 0, /*Set this bit to 1 to retrieve only queries with query feedback*/
@@ -178,9 +178,9 @@ BEGIN
WHEN N'@ignore_query_hashes' THEN 'a list of query hashes to ignore'
WHEN N'@ignore_plan_hashes' THEN 'a list of query plan hashes to ignore'
WHEN N'@ignore_sql_handles' THEN 'a list of sql handles to ignore'
- WHEN N'@query_text_search' THEN 'query text to search for'
- WHEN N'@query_text_search_not' THEN 'query text to exclude'
- WHEN N'@escape_brackets' THEN 'Set this bit to 1 to search for query text containing square brackets (common in .NET Entity Framework and other ORM queries)'
+ WHEN N'@query_text_search' THEN 'query text to search for; matched with LIKE, so _ % [ ] are treated as wildcards unless you set @escape_brackets = 1'
+ WHEN N'@query_text_search_not' THEN 'query text to exclude; matched with LIKE, so _ % [ ] are treated as wildcards unless you set @escape_brackets = 1'
+ WHEN N'@escape_brackets' THEN 'Set this bit to 1 to treat [, ], and _ as literals (not LIKE wildcards) in @query_text_search / @query_text_search_not; helpful for .NET Entity Framework and other ORM query text. Percent (%) stays a wildcard.'
WHEN N'@escape_character' THEN 'Sets the ESCAPE character for special character searches, defaults to the SQL standard backslash (\) character'
WHEN N'@only_queries_with_hints' THEN 'only return queries with query hints'
WHEN N'@only_queries_with_feedback' THEN 'only return queries with query feedback'
@@ -6985,20 +6985,21 @@ BEGIN
@work_end_utc
);
- IF @df = 1
- BEGIN
- SELECT
- @where_clause += N'AND DATEPART(WEEKDAY, qsrs.last_execution_time) BETWEEN 1 AND 5' + @nc10;
- END;/*df 1*/
- ELSE IF @df = 7
- BEGIN
- SELECT
- @where_clause += N'AND DATEPART(WEEKDAY, qsrs.last_execution_time) BETWEEN 2 AND 6' + @nc10;
- END;/*df 7*/
- ELSE
- BEGIN
- RAISERROR('Warning: @workdays filter does not support @@DATEFIRST = %i, weekday filter skipped', 10, 1, @df) WITH NOWAIT;
- END;
+ /*
+ Derive the weekday from the LOCAL execution time, not the raw UTC value, so
+ it stays consistent with the local work-hour window above: qsrs.last_execution_time
+ is UTC, so shift it back to local by -@utc_minutes_difference (embedded as a
+ literal because @utc_minutes_difference isn't in the shared @parameters list).
+ Without this, a query run on a local weekday morning that lands on the previous
+ UTC day (far-east time zones) was dropped as a weekend/wrong day.
+ DATEDIFF(DAY, 0, local) % 7 is @@DATEFIRST-independent (0 = Monday .. 4 = Friday,
+ since 1900-01-01 was a Monday), so one predicate covers every server instead of
+ only @@DATEFIRST 1 and 7 (the other values previously skipped the filter).
+ */
+ SELECT
+ @where_clause += N'AND DATEDIFF(DAY, 0, DATEADD(MINUTE, ' +
+ CONVERT(nvarchar(20), -1 * @utc_minutes_difference) +
+ N', qsrs.last_execution_time)) % 7 BETWEEN 0 AND 4' + @nc10;
IF @work_start_utc IS NOT NULL
AND @work_end_utc IS NOT NULL
From 1a52bc0a9244e3b3f41632709d55d4fcd6dc79c0 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 15 Jul 2026 12:32:44 -0400
Subject: [PATCH 15/60] sp_QueryReproBuilder: emit empty-result diagnostic to
match sp_QuickieStore
When the search window contained nothing, the main output selected from an empty
#repro_queries and returned a silent, unexplained empty result set. Match
sp_QuickieStore's behavior: guard the output with IF EXISTS and, when the table
is empty, return a single 'result = #repro_queries is empty' row so the caller
knows nothing was found (e.g. widen @start_date/@end_date) instead of guessing.
Verified: compiles clean on SQL 2016-2025; a future-dated (empty) window now
returns the diagnostic and a populated window still returns the normal results.
Co-Authored-By: Claude Opus 4.8
---
sp_QueryReproBuilder/sp_QueryReproBuilder.sql | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
index 089b3402..34d2c6be 100644
--- a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
+++ b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
@@ -4905,6 +4905,18 @@ WHERE NOT EXISTS
OPTION(RECOMPILE);
+/*
+Match sp_QuickieStore: when nothing landed in the search window there is no
+repro to build, so tell the caller which table came back empty instead of
+returning a silent, unexplained empty result set.
+*/
+IF EXISTS
+(
+ SELECT
+ 1/0
+ FROM #repro_queries AS rq
+)
+BEGIN
SELECT
table_name =
N'results',
@@ -5095,6 +5107,13 @@ OUTER APPLY
ORDER BY
qsrs.last_execution_time DESC
OPTION(RECOMPILE);
+END;
+ELSE
+BEGIN
+ SELECT
+ result =
+ N'#repro_queries is empty';
+END;
END TRY
From 0abb3eff6b51a94e275d52853ad6ee14ddf71ed6 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 16 Jul 2026 18:57:08 -0400
Subject: [PATCH 16/60] sp_IndexCleanup: apply usage floors per index, fix
silent include loss
Four defects, all sharing one failure mode: a merge script that cannot execute
paired with a DISABLE that can, so the loser's covering column disappears with
nothing absorbing it.
Single-winner key duplicates never merged their includes. Rule 5 settles a
unique-vs-non-unique pair in one pass, marking the unique index MERGE INCLUDES
and the loser DISABLE. That leaves one MERGE INCLUDES row, and
#key_duplicate_dedupe only collects groups holding more than one, so nothing
merged the loser's includes in. The winner was then downgraded to KEEP for
having no merged includes while the loser's DISABLE still ran. A plain unique
index accepts CREATE UNIQUE INDEX ... INCLUDE (...) WITH (DROP_EXISTING = ON) --
unlike a constraint-backed one, which fails with Msg 1907 -- so the merge is
legal and now happens.
FOR XML PATH('') re-encoded entities on the way out at six sites. A column named
[A&B] came back [A&B] and the script died with Msg 1911. Added TYPE plus
.value(), matching the pattern already used elsewhere in the procedure, and
escaped &, < and > before the CONVERT(xml, ...) at the three sites that split
include lists, which otherwise fail on the way in with Msg 9411.
XML node values were extracted as sysname. A column name does max out at 128
characters, but these values are already QUOTENAME'd, and QUOTENAME returns
nvarchar(258): a 128-character name arrives 130 characters long and lost its
closing bracket, so the script failed with Msg 103. Widened all ten extractions
to nvarchar(258).
@min_reads and @min_writes only ever applied per object. The filter behind
#filtered_objects groups sys.dm_db_index_usage_stats by object_id and sums usage
across every index on the table, so a busy table dragged in all of its indexes
regardless of the floor. They now also apply per index, via a meets_usage_floor
flag the dedupe rules check.
The flag is marked rather than deleted, and set after Rule 1, both deliberately.
Nonclustered compression reaches indexes through #index_analysis, so deleting the
row would take its compression recommendation with it, and compression
eligibility has nothing to do with usage. Rule 1 does not read the flag, so
unused-index detection still sees every index; screening ahead of it would make
it unreachable, since reads = 0 is a strict subset of everything a reads floor
removes. Help text updated to describe both levels.
Tests: added Erik's fixture scripts, previously kept outside the repo, plus a
README covering prerequisites, running order, and the baseline-diff technique.
The Expected: comments in them are the specification -- two of them caught the
bugs above. Bounded the WHILE 1 = 1 read loops, dropped the sp_BlitzIndex
dependency, and untracked a committed .pyc.
Corrected two run_tests.py assertions that encoded the pre-Msg-1907 behavior:
they asserted a merge into a unique constraint that SQL Server refuses to run.
Verified on 2016/2017/2019/2022/2025: compiles clean; run_tests.py 28/28;
no_access_test.py 4/4; generated merge scripts execute against real fixtures with
the includes landing; baseline diff versus HEAD shows only intended changes.
Co-Authored-By: Claude Opus 4.8
---
.gitignore | 2 +
sp_IndexCleanup/sp_IndexCleanup.sql | 459 ++++++++++++++++--
sp_IndexCleanup/tests/README.md | 101 ++++
.../__pycache__/run_tests.cpython-313.pyc | Bin 13352 -> 0 bytes
.../tests/fixtures_dupe_indexes.sql | 143 ++++++
.../tests/fixtures_more_dupe_indexes.sql | 194 ++++++++
.../tests/generate_index_reads.sql | 172 +++++++
sp_IndexCleanup/tests/manual_test_runs.sql | 76 +++
sp_IndexCleanup/tests/run_tests.py | 33 +-
9 files changed, 1142 insertions(+), 38 deletions(-)
create mode 100644 sp_IndexCleanup/tests/README.md
delete mode 100644 sp_IndexCleanup/tests/__pycache__/run_tests.cpython-313.pyc
create mode 100644 sp_IndexCleanup/tests/fixtures_dupe_indexes.sql
create mode 100644 sp_IndexCleanup/tests/fixtures_more_dupe_indexes.sql
create mode 100644 sp_IndexCleanup/tests/generate_index_reads.sql
create mode 100644 sp_IndexCleanup/tests/manual_test_runs.sql
diff --git a/.gitignore b/.gitignore
index 7958050e..a37cf3a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@
*.lock
.DS_Store
.claude/
+__pycache__/
+*.pyc
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index 5626be63..6f80f8fd 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -55,8 +55,8 @@ ALTER PROCEDURE
@database_name sysname = NULL, /*focus on a single database*/
@schema_name sysname = NULL, /*use when focusing on a single table/view, or to a single schema with no table name*/
@table_name sysname = NULL, /*use when focusing on a single table or view*/
- @min_reads bigint = 0, /*only look at indexes with a minimum number of reads*/
- @min_writes bigint = 0, /*only look at indexes with a minimum number of writes*/
+ @min_reads bigint = 0, /*only dedupe indexes with a minimum number of reads; an index clears by meeting this or @min_writes*/
+ @min_writes bigint = 0, /*only dedupe indexes with a minimum number of writes; an index clears by meeting this or @min_reads*/
@min_size_gb decimal(10,2) = 0, /*only look at indexes with a minimum size*/
@min_rows bigint = 0, /*only look at indexes with a minimum number of rows*/
@dedupe_only bit = 'false', /*only perform deduplication, don't mark unused indexes for removal*/
@@ -166,8 +166,8 @@ BEGIN TRY
WHEN N'@database_name' THEN 'the name of the database you wish to analyze'
WHEN N'@schema_name' THEN 'limits analysis to tables in the specified schema when used without @table_name'
WHEN N'@table_name' THEN 'the table or view name to filter indexes by, requires @schema_name if not dbo'
- WHEN N'@min_reads' THEN 'minimum number of reads for an index to be considered used'
- WHEN N'@min_writes' THEN 'minimum number of writes for an index to be considered used'
+ WHEN N'@min_reads' THEN 'minimum reads for an index to be deduped; applied per table AND per index, and an index clears by meeting this or @min_writes. Does not affect unused-index detection or compression'
+ WHEN N'@min_writes' THEN 'minimum writes for an index to be deduped; applied per table AND per index, and an index clears by meeting this or @min_reads. Does not affect unused-index detection or compression'
WHEN N'@min_size_gb' THEN 'minimum size in GB for an index to be analyzed'
WHEN N'@min_rows' THEN 'minimum number of rows for a table to be analyzed'
WHEN N'@dedupe_only' THEN 'only perform index deduplication, do not mark unused indexes for removal'
@@ -720,6 +720,15 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
target_index_name sysname NULL,
/* When this is a target, the index which points to it as a supersedes in consolidation */
superseded_by nvarchar(4000) NULL,
+ /*
+ Whether this index cleared @min_reads / @min_writes on its own. Defaults
+ to 1 and only drops to 0 when a caller sets a floor and this index misses
+ it. The dedupe rules ignore anything marked 0; Rule 1 and compression
+ analysis deliberately do not look at it, since an index can be too cold to
+ be worth deduping while still being worth reporting as unused or worth
+ compressing.
+ */
+ meets_usage_floor bit NOT NULL DEFAULT 1,
/* Priority score from 0-1 to determine which index to keep (higher is better) */
index_priority decimal(10,6) NULL,
/* Hash columns for optimized matching */
@@ -3438,6 +3447,72 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
OPTION(RECOMPILE);
END;
+ /*
+ Apply @min_reads / @min_writes per index, not just per object.
+
+ The filter that built #filtered_objects only asks whether a TABLE clears the
+ floors: it groups sys.dm_db_index_usage_stats by object_id, summing usage
+ across every index on the object. A table busy enough to qualify dragged in
+ all of its indexes with it, including ones nowhere near the floor the caller
+ asked for, which is not what '@min_reads: only look at indexes with a
+ minimum number of reads' promises.
+
+ This marks rather than deletes, and it sits between Rule 1 and the dedupe
+ rules. Both of those are deliberate:
+
+ - Marking keeps the row. Compression analysis reaches nonclustered indexes
+ through #index_analysis, and compression eligibility has nothing to do
+ with usage: an index can be far too cold to bother deduping and still be
+ worth compressing. Deleting the row would take its compression
+ recommendation with it.
+ - Rule 1 (unused indexes) has already run, and it does not look at this
+ flag, so a never-read index is still reported as unused. Screening ahead
+ of Rule 1 would make it unreachable: Rule 1's predicate is reads = 0,
+ which is a strict subset of everything a reads floor removes, so
+ @min_reads alone would silently switch unused-index detection off.
+
+ An index clears the screen if it meets EITHER floor, matching the object
+ filter's OR, with each term gated on its own threshold being set so that
+ passing only @min_reads doesn't leave user_updates >= 0 as an always-true
+ escape hatch on the write side.
+ */
+ IF @min_reads > 0
+ OR @min_writes > 0
+ BEGIN
+ UPDATE
+ ia
+ SET
+ ia.meets_usage_floor = 0
+ FROM #index_analysis AS ia
+ WHERE EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_details AS id
+ WHERE id.index_hash = ia.index_hash
+ AND NOT
+ (
+ (
+ @min_reads > 0
+ AND (id.user_seeks + id.user_scans + id.user_lookups) >= @min_reads
+ )
+ OR
+ (
+ @min_writes > 0
+ AND id.user_updates >= @min_writes
+ )
+ )
+ )
+ OPTION(RECOMPILE);
+
+ SET @rc = ROWCOUNT_BIG();
+
+ IF @debug = 1
+ BEGIN
+ RAISERROR('Index-level reads/writes filter excluded %I64d indexes from dedupe analysis', 0, 0, @rc) WITH NOWAIT;
+ END;
+ END;
+
/* Rule 2: Exact duplicates - matching key columns and includes */
UPDATE
ia1
@@ -3468,6 +3543,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia1.exact_match_hash = ia2.exact_match_hash /* Exact match: keys + includes + filter */
WHERE ia1.consolidation_rule IS NULL /* Not already processed */
AND ia2.consolidation_rule IS NULL /* Not already processed */
+ /* Both sides must have cleared @min_reads / @min_writes on their own */
+ AND ia1.meets_usage_floor = 1
+ AND ia2.meets_usage_floor = 1
/* Exclude unique constraints - we'll handle those separately in Rule 7 */
AND NOT EXISTS
(
@@ -3587,6 +3665,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia1.is_unique = 0
WHERE ia1.consolidation_rule IS NULL /* Not already processed */
AND ia2.consolidation_rule IS NULL /* Not already processed */
+ /* Both sides must have cleared @min_reads / @min_writes on their own */
+ AND ia1.meets_usage_floor = 1
+ AND ia2.meets_usage_floor = 1
/* Don't disable unique constraints */
AND NOT EXISTS
(
@@ -3777,6 +3858,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ISNULL(ia1.included_columns, '') <> ISNULL(ia2.included_columns, '') /* Different includes */
WHERE ia1.consolidation_rule IS NULL /* Not already processed */
AND ia2.consolidation_rule IS NULL /* Not already processed */
+ /* Both sides must have cleared @min_reads / @min_writes on their own */
+ AND ia1.meets_usage_floor = 1
+ AND ia2.meets_usage_floor = 1
/* Exclude pairs where either one is a unique constraint (we'll handle those separately in Rule 7) */
AND NOT EXISTS
(
@@ -3866,16 +3950,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
SELECT DISTINCT
N', ' +
- t.c.value('.', 'sysname')
+ t.c.value('.', 'nvarchar(258)')
FROM
(
- /* Superset's own includes */
+ /*
+ Superset's own includes. Entities are escaped before the
+ split or CONVERT(xml, ...) fails outright with Msg 9411
+ on a legal column name like [A&B].
+ */
SELECT
x = CONVERT
(
xml,
N'' +
- REPLACE(superset.included_columns, N', ', N'') +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(superset.included_columns, N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
N''
)
WHERE superset.included_columns IS NOT NULL
@@ -3888,7 +3991,22 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
xml,
N'' +
- REPLACE(subset.included_columns, N', ', N'') +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(subset.included_columns, N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
N''
)
FROM #index_analysis AS subset
@@ -3900,12 +4018,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
) AS a
CROSS APPLY a.x.nodes('/c') AS t(c)
/* Filter out columns already in superset's key */
- WHERE CHARINDEX(t.c.value('.', 'sysname'), superset.key_columns) = 0
- AND LEN(t.c.value('.', 'sysname')) > 0
+ WHERE CHARINDEX(t.c.value('.', 'nvarchar(258)'), superset.key_columns) = 0
+ AND LEN(t.c.value('.', 'nvarchar(258)')) > 0
+ /* TYPE plus .value() so the entities don't come back re-encoded */
FOR
XML
- PATH('')
- ),
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
1,
2,
''
@@ -4003,6 +4123,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
FROM #index_analysis AS ia1
WHERE ia1.consolidation_rule IS NULL /* Not already processed */
AND ia1.action IS NULL /* Not already processed by earlier rules */
+ /* Must have cleared @min_reads / @min_writes on its own */
+ AND ia1.meets_usage_floor = 1
/*
A filtered index can never stand in for a unique constraint: the
constraint enforces uniqueness over EVERY row, the filtered index
@@ -4396,16 +4518,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
(
SELECT DISTINCT
- N', ' + t.c.value('.', 'sysname')
+ N', ' + t.c.value('.', 'nvarchar(258)')
FROM
(
- /* Winner's includes */
+ /*
+ Winner's includes. Entities are escaped first or
+ CONVERT(xml, ...) fails with Msg 9411 on a legal
+ column name like [A&B].
+ */
SELECT
x = CONVERT
(
xml,
N'' +
- REPLACE(ISNULL(ia_winner.included_columns, N''), N', ', N'') +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(ISNULL(ia_winner.included_columns, N''), N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
N''
)
WHERE ia_winner.included_columns IS NOT NULL
@@ -4418,7 +4559,22 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
xml,
N'' +
- REPLACE(ia_loser.included_columns, N', ', N'') +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(ia_loser.included_columns, N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
N''
)
FROM #index_analysis AS ia_loser
@@ -4430,11 +4586,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia_loser.included_columns IS NOT NULL
) AS a
CROSS APPLY a.x.nodes('/c') AS t(c)
- WHERE LEN(t.c.value('.', 'sysname')) > 0
+ WHERE LEN(t.c.value('.', 'nvarchar(258)')) > 0
+ /* TYPE plus .value() so the entities don't come back re-encoded */
FOR
XML
- PATH('')
- ),
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
1,
2,
''
@@ -4455,10 +4613,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia_loser.action = N'DISABLE'
AND ia_loser.consolidation_rule = N'Key Duplicate'
AND ia_loser.target_index_name = ia_winner.index_name
+ /* TYPE plus .value() so an index named [a&b] isn't reported as [a&b] */
FOR
XML
- PATH('')
- ),
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
1,
2,
''
@@ -4476,10 +4636,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia_loser.action = N'DISABLE'
AND ia_loser.consolidation_rule = N'Key Duplicate'
AND ia_loser.target_index_name = ia_winner.index_name
+ /* TYPE plus .value() so an index named [a&b] isn't reported as [a&b] */
FOR
XML
- PATH('')
- ),
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
1,
2,
''
@@ -4525,6 +4687,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia1.index_name < ia2.index_name /* Only process each pair once */
AND ia1.consolidation_rule IS NULL /* Not already processed */
AND ia2.consolidation_rule IS NULL /* Not already processed */
+ /* Both sides must have cleared @min_reads / @min_writes on their own */
+ AND ia1.meets_usage_floor = 1
+ AND ia2.meets_usage_floor = 1
WHERE
/* Leading columns match */
EXISTS
@@ -4871,16 +5036,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
SELECT DISTINCT
N', ' +
- t.c.value('.', 'sysname')
+ t.c.value('.', 'nvarchar(258)')
FROM
(
- /* Create XML from winner's includes */
+ /*
+ Create XML from winner's includes. Entities are
+ escaped first or CONVERT(xml, ...) fails with
+ Msg 9411 on a legal column name like [A&B].
+ */
SELECT
x = CONVERT
(
xml,
N'' +
- REPLACE(ISNULL(kdi.winner_includes, N''), N', ', N'') +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(ISNULL(kdi.winner_includes, N''), N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
N''
)
FROM KeyDuplicateIncludes AS kdi
@@ -4895,7 +5079,22 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
xml,
N'' +
- REPLACE(kdi.loser_includes, N', ', N'') +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(kdi.loser_includes, N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
N''
)
FROM KeyDuplicateIncludes AS kdi
@@ -4905,11 +5104,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/* Split XML into individual columns */
CROSS APPLY a.x.nodes('/c') AS t(c)
/* Filter out empty strings that can result from NULL handling */
- WHERE LEN(t.c.value('.', 'sysname')) > 0
+ WHERE LEN(t.c.value('.', 'nvarchar(258)')) > 0
+ /* TYPE plus .value() so the entities don't come back re-encoded */
FOR
XML
- PATH('')
- ),
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
1,
2,
''
@@ -4970,6 +5171,204 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
OPTION(RECOMPILE);
+ /*
+ Rule 5 settles a unique-vs-non-unique key duplicate in a single pass: the
+ unique index is marked MERGE INCLUDES and the non-unique loser is marked
+ DISABLE immediately. That leaves the group holding exactly one MERGE INCLUDES
+ row, and #key_duplicate_dedupe above only collects groups holding more than
+ one (HAVING COUNT_BIG(*) > 1), so nothing ever merged the loser's includes
+ into the winner. The winner was then downgraded to KEEP further down for
+ having no merged includes, while the loser's DISABLE still ran: any include
+ the winner did not already carry was silently dropped.
+
+ Rule 5 excludes unique constraints on both sides, so a winner reaching here
+ is always a plain unique index. Unlike a constraint-backed index (Msg 1907),
+ that accepts CREATE UNIQUE INDEX ... INCLUDE (...) WITH (DROP_EXISTING = ON),
+ so the merge is safe to emit.
+
+ Only rows whose merged list actually differs from what the winner already
+ covers are recorded. When a loser's includes are a subset of the winner's,
+ the existing KEEP plus the loser's DISABLE is already correct, and emitting a
+ rebuild that changes nothing would be noise.
+ */
+ INSERT INTO
+ #merged_includes
+ WITH
+ (TABLOCK)
+ (
+ scope_hash,
+ index_name,
+ key_columns,
+ merged_includes
+ )
+ SELECT
+ kd.scope_hash,
+ kd.index_name,
+ kd.key_columns,
+ kd.merged_includes
+ FROM
+ (
+ SELECT
+ ia.scope_hash,
+ ia.index_name,
+ ia.key_columns,
+ original_includes = ia.included_columns,
+ merged_includes =
+ STUFF
+ (
+ (
+ SELECT DISTINCT
+ N', ' +
+ t.c.value('.', 'nvarchar(258)')
+ FROM
+ (
+ /* The winner's own includes */
+ SELECT
+ x =
+ CONVERT
+ (
+ xml,
+ N'' +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(ia.included_columns, N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
+ N''
+ )
+ WHERE ia.included_columns IS NOT NULL
+
+ UNION ALL
+
+ /* Every duplicate this winner is about to disable */
+ SELECT
+ x =
+ CONVERT
+ (
+ xml,
+ N'' +
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE
+ (
+ REPLACE(loser.included_columns, N'&', N'&'),
+ N'<',
+ N'<'
+ ),
+ N'>',
+ N'>'
+ ),
+ N', ',
+ N''
+ ) +
+ N''
+ )
+ FROM #index_analysis AS loser
+ WHERE loser.scope_hash = ia.scope_hash
+ AND loser.key_filter_hash = ia.key_filter_hash
+ AND loser.target_index_name = ia.index_name
+ AND loser.action = N'DISABLE'
+ AND loser.consolidation_rule = N'Key Duplicate'
+ AND loser.included_columns IS NOT NULL
+ ) AS a
+ CROSS APPLY a.x.nodes('/c') AS t(c)
+ /*
+ nvarchar(258), not sysname. A column name does max out at
+ 128 characters, but these values are already wrapped by
+ QUOTENAME, and QUOTENAME returns nvarchar(258): a
+ 128-character name comes back 130 characters long, and
+ pulling that through a sysname bucket lops off the closing
+ bracket. The merge script then fails with Msg 103 while the
+ paired DISABLE of the loser still runs.
+ */
+ /* A column already in the key doesn't need including */
+ WHERE CHARINDEX(t.c.value('.', 'nvarchar(258)'), ia.key_columns) = 0
+ AND LEN(t.c.value('.', 'nvarchar(258)')) > 0
+ /*
+ TYPE plus .value() on the way out is not optional here.
+ A bare FOR XML PATH('') returns nvarchar and re-encodes
+ entities, so a column named [A&B] would come back out as
+ [A&B] and land in the generated script, which then
+ fails with Msg 1911 (column does not exist) while the
+ paired DISABLE of the loser still runs - the exact silent
+ include loss this block exists to prevent.
+ */
+ FOR
+ XML
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
+ 1,
+ 2,
+ N''
+ )
+ FROM #index_analysis AS ia
+ WHERE ia.action = N'MERGE INCLUDES'
+ AND ia.consolidation_rule = N'Key Duplicate'
+ /* Leave the multi-winner groups handled above alone */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #merged_includes AS mi
+ WHERE mi.scope_hash = ia.scope_hash
+ AND mi.index_name = ia.index_name
+ )
+ AND EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_analysis AS loser_check
+ WHERE loser_check.scope_hash = ia.scope_hash
+ AND loser_check.key_filter_hash = ia.key_filter_hash
+ AND loser_check.target_index_name = ia.index_name
+ AND loser_check.action = N'DISABLE'
+ AND loser_check.consolidation_rule = N'Key Duplicate'
+ AND loser_check.included_columns IS NOT NULL
+ )
+ ) AS kd
+ WHERE ISNULL(kd.merged_includes, N'') <> ISNULL(kd.original_includes, N'')
+ OPTION(RECOMPILE);
+
+ /*
+ Apply them. Winners handled by the multi-winner path above already carry
+ their merged list, so this only moves the single-winner rows just recorded.
+ */
+ UPDATE
+ ia
+ SET
+ ia.included_columns = mi.merged_includes
+ FROM #index_analysis AS ia
+ JOIN #merged_includes AS mi
+ ON mi.scope_hash = ia.scope_hash
+ AND mi.index_name = ia.index_name
+ WHERE ia.action = N'MERGE INCLUDES'
+ AND ia.consolidation_rule = N'Key Duplicate'
+ AND ISNULL(ia.included_columns, N'') <> ISNULL(mi.merged_includes, N'')
+ OPTION(RECOMPILE);
+
+ IF @debug = 1
+ BEGIN
+ SELECT
+ table_name = '#index_analysis after single-winner key duplicate merge',
+ ia.*
+ FROM #index_analysis AS ia
+ OPTION(RECOMPILE);
+ END;
+
/* Find indexes with same key columns where one has includes that are a subset of another */
IF @debug = 1
BEGIN
diff --git a/sp_IndexCleanup/tests/README.md b/sp_IndexCleanup/tests/README.md
new file mode 100644
index 00000000..a6379f46
--- /dev/null
+++ b/sp_IndexCleanup/tests/README.md
@@ -0,0 +1,101 @@
+# sp_IndexCleanup Tests
+
+Two kinds of thing live here: an automated suite that asserts, and fixture
+scripts that set up realistic scenarios for you to inspect by hand.
+
+**Run the automated suite before and after any change to `sp_IndexCleanup.sql`.**
+Compiling proves nothing about behavior. The procedure's whole output is
+generated DDL, and a script that looks right but cannot execute is worse than no
+script at all -- the paired `DISABLE` still runs.
+
+## Automated
+
+| Script | What it does |
+| --- | --- |
+| `run_tests.py` | Runs `adversarial_test.sql`, parses the output, asserts 28 expectations across 12 rule groups. |
+| `adversarial_test.sql` | Builds synthetic `test_ic_*` tables covering unique constraints, sort directions, filters, include merges, indexed views, heaps, and rule interactions. Self-cleaning. Driven by `run_tests.py`; run it directly only to eyeball output. |
+| `no_access_test.py` | Creates a login with no user-database access and asserts the `HAS_DBACCESS` preflight returns instead of spinning at 100% CPU forever (PerformanceMonitor #915). Drops the login afterward. |
+
+```
+cd sp_IndexCleanup/tests
+python run_tests.py --server SQL2022
+python no_access_test.py --server SQL2022
+```
+
+Both take `--server` and `--password` (default `SQL2022` / the standard local sa
+password). Expect `28 passed` and `4 passed`.
+
+These are **not** wired into CI. `.github/workflows/sql-tests.yml` only runs
+basic-execution and help-output smoke tests, which is why behavioral regressions
+have shipped before. Run these by hand.
+
+## Fixtures (manual)
+
+These build indexes on the real `StackOverflow2013.dbo.Users` table (~2.4M rows),
+so unlike the synthetic suite they exercise real data distribution and sizes.
+
+| Script | What it does |
+| --- | --- |
+| `fixtures_dupe_indexes.sql` | ~24 indexes covering duplicates, subsets, sort directions, filters, mergeable includes, and deliberately unused indexes. |
+| `generate_index_reads.sql` | Drives ~100 reads through each of the above so usage stats exist. ~2 minutes. |
+| `fixtures_more_dupe_indexes.sql` | Numbered cases 1-8d, each annotated with its expected behavior. The only fixtures covering unique **constraints** (7a/7b/7c) next to unique **indexes** (3, 5). Self-contained: builds, generates reads, and runs the procedure. |
+| `manual_test_runs.sql` | Assorted invocations -- scoping, filters, `@dedupe_only`, `@help`. |
+
+Order for the first set:
+
+```
+fixtures_dupe_indexes.sql -> generate_index_reads.sql -> manual_test_runs.sql
+```
+
+`fixtures_more_dupe_indexes.sql` stands alone.
+
+### Prerequisites
+
+- A **scratch** `StackOverflow2013`. Both fixture scripts begin with
+ `EXECUTE dbo.DropIndexes`, which drops every nonclustered index in the
+ database. Do not point them at anything you care about.
+- A `dbo.DropIndexes` helper procedure in that database.
+- `fixtures_more_dupe_indexes.sql` adds three unique constraints
+ (`uq_test_c1`/`c2`/`c3`). `DropIndexes` does not remove constraints -- drop
+ them explicitly with `ALTER TABLE dbo.Users DROP CONSTRAINT ...` when
+ cleaning up.
+
+### The `Expected:` comments are the specification
+
+They are not decoration. Two of them caught bugs that shipped, that compiled
+cleanly, and that the automated suite did not cover:
+
+- **Case 7c** -- the procedure emitted a merge into a unique *constraint*:
+ `CREATE INDEX ... WITH (DROP_EXISTING = ON)` against a constraint-backed
+ index, which SQL Server refuses with **Msg 1907**. The paired `DISABLE` of
+ `ix_c3` ran fine, so the net effect was losing an index whose include nothing
+ absorbed.
+- **Case 3** -- `ix_test_5` was disabled with `uq_test_1` as its target, but
+ nothing merged `ix_test_5`'s include into it, so `LastAccessDate` coverage
+ vanished. A plain unique *index* does accept `DROP_EXISTING` with an added
+ `INCLUDE` (unlike a constraint), so the merge is legal and now happens.
+
+Both were found by diffing output against these comments by hand. Nothing here
+asserts them automatically -- **wiring these cases into `run_tests.py` is the
+most valuable improvement available to this directory.**
+
+## Verifying a change
+
+The technique that catches regressions is a **baseline diff**: identical
+database state, two builds, compare output.
+
+```
+git show :sp_IndexCleanup/sp_IndexCleanup.sql > /tmp/base.sql
+```
+
+Build fixtures once, generate reads once, then install each build in turn and
+capture output. Normalize the `run date:` stamp before diffing, and compare rows
+as a set -- a removed row shifts every line after it and makes a positional diff
+useless.
+
+Then ask of every difference: intended, or a regression? A change with no
+explanation is the finding.
+
+Finally, for anything that alters generated DDL: **execute the generated script**
+against the fixture and confirm the result. That is the check that distinguishes
+a script that reads correctly from one that runs.
diff --git a/sp_IndexCleanup/tests/__pycache__/run_tests.cpython-313.pyc b/sp_IndexCleanup/tests/__pycache__/run_tests.cpython-313.pyc
deleted file mode 100644
index 6418504477977c651646c5a6db6b11aa01f01b32..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 13352
zcmd5iX>1!;dNbtkCMD`Vtk|Q=5@nOtWlN@P$Fgo)l5IWFRw5_th#W~wC{msweN57g
zy4e(Rl3mkC9MkDyqaZC-M$)eO&p{Wcvx@@$NiUs*nHcRB8=%cUUqzatK>EEI&WNHY
zTNQ$$W9#wFoA_M>d<8faiVtKLrlE3F1%qMR_#o%A@C@a+N>?M<5buj*%QG
z{c1Rk^sD8xNLxv86r!NiAsv)@q=(Xg3{V=+n2^yuK@g0QAkG+Th`njRG%=%@(Kt-P
zznl4a?@-9kUg!z3Oei`}cl*z=9M5n8CPx-~{8oES-5#D7sdw6Dsb(?8;
z<}XHO!y%fBh9ro*YyKiV>2&fe2lUeDC_u^}VGn_{h(bCKiS)>TjL3w{$bzhU@IjEjr~}c_ky)0;62lxx@<|IVEi;#>
z2T}Uw{DQ`b;Ljs!cm{q?z@J|%KoQ3G@^KKzbTE9rwYjAYngc>{E_J|{)9}ZOq-i{`
znj_gqtS+u!qL%#2y(^6=qcxFNEtPdp!v978eT&|rU+8(9N92`X
ze(}v0<2@_X&Gy?xcWfgI{V7Yq<%5?F#&>?;yzWd`8WL2)BLGeKKtk$cfSP1EFM%ao
zCH4>@(nE}8_DFX{LMz#@PmmaDM
z+L(P%hp|$d`8c
zFy#_9T^7Ociu4LLwJ)anT;^zrkJkud63b3{o<_L>6B*pUB?xK!2bp)*L{DpMWN=-jaIE=#H6AZ_*be5OU5oXHiXM=&c0G>Z(@1+BwNSNlr=XsY%nny(o
z@8bgV5pU$;JS&<5c+U5Rm^qfi8WFTkr}JApH#rQVj-L+(BJdM|0pRlyhKum$1Cd!#
z8)QQw)!dS-r}4f!1=4l^fG7@MdAXJ&cPFv~K2SV}|+zl-FoNcu#|7Y^d7
z1NvdX;h8{sxFS5>I*++a&puoeG*!VL{}_q|;*S>FLT}1iczO8JaJ)Ea-M-M5$}3zL
z_{LtcNTu?NE(@20%dcK~^($*}%2FQhO=P8F+8#PD96HZ=$I4UVdS$
z{jH0?>{&AW)$r%}jrU3`f7up4pWNn5mbzlvl)d~T87BBH!0fMsHvL$RX+gm>
z(SbicsRxF`z{-GhA&MrB_l+FKcyiLTeyYLK5~j(JL6eanWUL;27VNT-KZkEfq8v70
z6MHdj9=-c~dWv~lr9vaQBBk&yO+YKqS2LzgVK6lPc+|;(CPsdrc4NG(8-<2`@HNQ~
z={Y9in}wB*W`aR_IuMM&&IOzjegceZT-1BL!NAMxXyCto_~D0sk@9*2p>O{i>aeWp
zpw}EP8o}FSSJc4@4=Zjp5a2L!HSi-EWRT!`5+au89JpC0y@}eF*^8nMwp&pamSk{4
z5E7AgKU(%b(=?Fk#m>of!pAoF^w%ox?Naut?EiueEQ3R`a1>dfuIj3Ry@gicjmaR`$nR;3^3r2C7;*t
zQ$IdKY-{>%J7FzY+?%o%T^_wO8gKi;y5m7TVK2VFldu*ojB+Kw*cykz6Oy#5UjV~+
zk-{!DMV!Bpi3_KF&Lw+Q<^jfbp(br2aQ)ivFtx)1EZ2f?gN
zxxVEQZJNDc`uL%VEa-Uesl(7GRrf;HN-)79btkX5DC{j{3Tz(jMwn2ZM+^9<4e{mU
zy>A9L_FNQinVSNDUWkhJ>r`nK=Y(JKBx#}2HtmM=mu1mko)i97wNa5n8X0i-2MuUXPWW4;zX~;@7SMqu!;V@}TTUEX
z(}98^(^6Ye`;UrY?;0JnqJ0}c-^x7OfjZItEGu=Rt_?73#YzvLgXmBehG)^?9}z=0
zI+EoFJ*YP){H@wiAL>T~Sv(A)p`19jW=BWSF*KZoVFZol#IRK^j-wM^17bzSS5cN2I
z$3o!F&qkO)&_T*a4IPIgPuu}0M3XNZ@&_=1+#&pKNR!6n-6}p=EXcJyED3-IG*ri8rSA=G_jxQ|9XV?!s#A+l+P2uYBY;~ffl5PVGw*8ozZq^
zr^y?XVCxy{>mKi;J)=V>J$(>!l|nlrF?xd(wKHtwhDOx5nniMs$AKtXIo=(ZVM7pQ
z=MITgIkcdD^1Zf$Hp(6pt%~B{yz3L%TBg>kqY;On
zAfn|9M*S@B$X1V{`zfDbKE__8k)%oo#q|M!oT_lKgz?J;%LD<}bXY!c3%gr<63S3G
zA(dV4$C|}dVE~P1iZ)w!eNmLHo8&0uV{8C_!*zzZ1HE2FS7~6L&Qx5m$oa>
z98ELt^Z(yi4>yEGf^Cu;g(A~HxRbbb42oBA1>s^G3ZW76V}+Vlu&v%c)Ih$E5HEpdK(hSHzk55FVTDhb;@Hg)?c1rJ5);suFyK^Y*j)JQlq=5YnXr(1j^{awc
ze3pi3iKnI6QrMo^KNyBpPX4Mm1vVRu)hFWy_#h+9Kg?8DuhAOoSc6L3hctKilVoUcfjW8t=uXC+ZU9l)#ADMm2u
zcaF&&g#G)mBviA2+h#wGghC*44HL@B-8co-#PWfekV7m`kjdWRWzR|sOtWXfL2;X4
zX=zS-WZzk#rKK5Ash)9;hlNHS2`-x3vkDaquz%p#`5@y1j-*AN!~2N}g{q$8qiB4r
zduVh#y|oZ*EqD(BTuOt4=9ctAD0zV-aiP
zVeJ}F-;!RJ)AE_av+DWEUe|ykJvqX@xmMZWbIZcv>H2IM@ro9!_=b
z?)8g1c3=s@l+YlJ8VUIQLD*)<8@9&oBkRXt7m~;LgzeIx(*dxj2o1e^9FiIM13cao
z`c(>jLS0L1j?G8bqzYT%V;~z?yKs=4+YW`OWkMGnIw=g#b>mjN^ApV=AFsUpbBCcp
z-54KE$J^Ci821Kz#)~rl9qb!n?<)n|zoYaz%i~fazdTMYSKVq#)wMs+lKJ}<2L6@G
zkLeTT2k+U7Vgv73m&iNz>ZMbk^sE>@9$r3_sC7TqlDY$rjmpqnsk&A?v>oot3X1O(
zk9>AISv-;~7+E;_l?>|Hl)W-O{nu!@?4vVFqlwo3Tf(hiq6%R^9uYaJ0In6|qG1@z
zL8rmWc%UbA_LwnId1!Og>`3&TUMC6|-kW=PrYK}85{|A^|
z2?c=M&ckFJ!y0)mtq~YL_;fO4yHX%z*dz?^&OLl1>}%{{bJ*9YLY6@*Rs|ThtO{#l
z`{<^qSeDh9OO60z^Xl|qEv$OxCh(_u*q&-^Tk$3u`m)uyu{o%M+yscH=|Yj@tuyNk
z!Is!Dlv{@D?GcNyFpqAM
zjIP_3bz?X1>vb%2-L|e9`)qE!nWj+q1hV^b4_8pbJ?`q2EG&ZRV}+V@CHt2UX}Ah0
zdLg)Ug-ckqL#|XjxRfOz7a-%(boDA0vL4|4D`h>jdmwF6N{-wHxB8Gs=>(`5=|1Z9
zt-BDi6X0@qqE_yMqgx?jWG9S*>rxCs$_;Qm9g5WNNERe=1a=cRy0e-}g!CTBPDBQ{
z>Lg`WGruZ%70|YtazrXoBRQi386oolhBB@AVlZ9b4gBFtZs|ehT(CWAeg!~Rx)Nhp
z4_OafcV|+ElklX+qo$sP3>nxg8Io0#tAI!AQ+ZUgEj;QtWRvu^e+@r*x%ff(9=(#m
z!GPjCdPE>QMI0wlfg-X0C8q@{rz$-m<0KBgN)IZ8V7xKM1-&VwPtgW_%y{G==qzOM
zSWq#xG?jV?JE0PwyEJ6YkZYO83Q{bG-W9kv)GMLBEnTmI`ZMYJ_BPY1ghn8fXRXa;
zSksKj-Q2EYL(U49z7K}=UG1m`Z1*4LWESOi*kcAqE
z=ZB=MpVh2rDaRMW`ZG)*1cwqq>vFjSYJ{Jm0VbT7gLKP2VO!tW*zqy9lnM-~K4A_H
z_-sVVk#-BF9yy!ZPYcyb#vcx=($idcE;~U`j#)OR;f;pZ3`i&a`dxI-EbDt&LI;8H
z=)6lXKzc7=b_X8AJR}$z*}3`1MaP;$DxRLDF!uqdWQ4h4(hL_MBGZJW=?}855t$Ba
z7pKG88Y!%;rP}!GIr*Y{&$63A6AfF5ZG-9qHW9q4nrm
zGCL$PC;ql8O(ypXJRHkHq)v1rBvSp|L&JiWrY8l>3$%0rBWT>TbP0o-8|CX4(jl0}
zxZhIj)WsjP_SW;eZBN}A6!?u1r1FL1>JDvgySH5dStc3
zENc0Skif}sGv`DT8;U~M7oxB7kykg*!2w&!z!oX?LLed~Y)S`N7#5v#*f_i>aCV6i
z00F7y0XQggGg7iB2Qnn&1M^{AllaxkgmQ1=F_2r%M4~*8Q%mt}lKRMpX9^nu_bd3=
zfaO^RdVwn=TPfsl=T|HYKGqY~{L7t}I%D0jBZ~(XdhZo(i}${_=aPPrOj+_`O-W1D
zQo+*64@*8NS|0znB3a#+vuf}%LN~LK0zzRA3ui^%*wVc
zk=OgKyz=JY0~1kH^Vmx0%n7S&`P}mCiZ5Yy-=(_lQKp{_d{eSxNuMlf`HRto{y!RQ
zvC^cWEPix(XR@mKp3#=b+jZBt8!C&_Z`1gtyD_N1-<8Hom#C$p>s2ey-8DX&
z2{*4SPQ4Xg7yzN<72L76T^~r=+mg1nh5mas`yG1+e(y-yIu`o>WGe>wY^#b-e9(2J
zbh&>~53pm}SbwZD-Wq3?4A)3v}k64M<$XrehQF=a$Yb%`OLitGzk!57w2R+&Yyo58tImuxQBF1wiq(0rL>P
zYux^ILHS~Ls;D?Najol0*FAelToZ4JpT6#n?_Qc(E?C}oqhi^z(zbH;X8GU0vNCt8
zB~jYf-L-+Fn>*$9_r0x 10000;
+
+/* Filtered index with same column but different filter - should be kept */
+CREATE INDEX IX_Users_Reputation_MediumRep
+ON dbo.Users (Reputation)
+WHERE Reputation >= 1000 AND Reputation <= 10000;
+
+/* Index with included columns that overlap with another index - mergeable */
+CREATE INDEX IX_Users_LastAccessDate
+ON dbo.Users (LastAccessDate)
+INCLUDE (Location, WebsiteUrl);
+
+/* Index with one additional include column - candidate to supersede the above */
+CREATE INDEX IX_Users_LastAccessDate_Extended
+ON dbo.Users (LastAccessDate)
+INCLUDE (Location, WebsiteUrl, Age);
+
+/* Index that will never be used - should be detected as unused */
+CREATE INDEX IX_Users_Unused
+ON dbo.Users (DownVotes, UpVotes);
+
+/* Another unused index but with includes - should be detected as unused */
+CREATE INDEX IX_Users_Unused_WithIncludes
+ON dbo.Users (Views)
+INCLUDE (AccountId);
+
+/* Index that partially overlaps - might be kept */
+CREATE INDEX IX_Users_Location_Age
+ON dbo.Users (Location, Age);
+
+/* Composite key index that could be useful - should be kept */
+CREATE INDEX IX_Users_AccountId_DisplayName
+ON dbo.Users (AccountId, DisplayName);
+
+/* Another candidate for merging */
+CREATE INDEX IX_Users_AccountId_Reputation
+ON dbo.Users (AccountId)
+INCLUDE (Reputation);
+
+/* Same key columns in different order (Rule 7) */
+CREATE INDEX IX_Users_Age_Location
+ON dbo.Users (Age, Location);
+
+/* Superset key with additional columns compared to IX_Users_Reputation */
+CREATE INDEX IX_Users_Reputation_CreationDate
+ON dbo.Users (Reputation DESC, CreationDate)
+INCLUDE (DisplayName);
+
+/* Similar index with more includes (extended) but without the naming convention */
+CREATE INDEX IX_Users_LastAccessDate_Full
+ON dbo.Users (LastAccessDate)
+INCLUDE (Location, WebsiteUrl, Age, DisplayName, Reputation);
+
+/* Index with exact same columns as IX_Users_DisplayName_Reputation but different order after first */
+CREATE INDEX IX_Users_DisplayName_RepAge
+ON dbo.Users (DisplayName, Age, Reputation);
+
+/* Index with mismatched filter predicate that's a subset of another */
+CREATE INDEX IX_Users_Reputation_VeryHighRep
+ON dbo.Users (Reputation)
+WHERE Reputation > 50000;
+
+/* Index with mismatched filter predicate that's different */
+CREATE INDEX IX_Users_Reputation_Equals_One
+ON dbo.Users (Reputation)
+WHERE Reputation = 1;
+GO
diff --git a/sp_IndexCleanup/tests/fixtures_more_dupe_indexes.sql b/sp_IndexCleanup/tests/fixtures_more_dupe_indexes.sql
new file mode 100644
index 00000000..a733f944
--- /dev/null
+++ b/sp_IndexCleanup/tests/fixtures_more_dupe_indexes.sql
@@ -0,0 +1,194 @@
+/*
+sp_IndexCleanup Test Fixtures - Unique Constraint and Dedupe Rule Cases
+=======================================================================
+Builds a numbered set of dedupe cases on the real StackOverflow2013.dbo.Users
+table, each annotated with the behavior it is supposed to produce. This is the
+higher-value fixture set of the two: it is the only one that covers unique
+CONSTRAINTS (cases 7a/7b/7c) alongside unique INDEXES (cases 3 and 5), and that
+distinction drives real branches in the procedure.
+
+The "Expected:" comments are the specification. They earn their keep - two of
+them caught real bugs that shipped and that neither compiling nor the
+adversarial suite noticed:
+
+ - Case 7c: the procedure used to emit a merge into the unique constraint
+ (CREATE INDEX ... WITH (DROP_EXISTING = ON) against a constraint-backed
+ index, which SQL Server rejects with Msg 1907) while still emitting a
+ runnable DISABLE of ix_c3 - losing that index's LastAccessDate include with
+ nothing absorbing it.
+ - Case 3: the procedure disabled ix_test_5 with uq_test_1 as the target but
+ never merged ix_test_5's include into it, silently dropping LastAccessDate
+ coverage. Unlike a constraint, a plain unique index does accept
+ DROP_EXISTING with an added INCLUDE, so the merge is legal and now happens.
+
+Nothing here asserts automatically. Verifying still means running sp_IndexCleanup
+and reading the output against the comments. See README.md.
+
+Prerequisites:
+ - StackOverflow2013 database
+ - dbo.DropIndexes helper procedure
+
+WARNING: starts by dropping every nonclustered index on the database via
+dbo.DropIndexes. Only run against a scratch copy of StackOverflow2013.
+*/
+USE StackOverflow2013;
+GO
+
+EXECUTE dbo.DropIndexes;
+GO
+
+/* 1. Exact duplicates, not unique */
+
+/* Test Case 1: Exact duplicates (same keys, same includes) */
+CREATE INDEX ix_test_1 ON dbo.Users(DisplayName) INCLUDE(Reputation);
+CREATE INDEX ix_test_2 ON dbo.Users(DisplayName) INCLUDE(Reputation);
+/* Expected: One index should be kept, other should be disabled */
+
+/* 2. Key duplicates, not unique */
+
+/* Test Case 2: Key duplicates with different includes */
+CREATE INDEX ix_test_3 ON dbo.Users(AccountId) INCLUDE(Reputation);
+CREATE INDEX ix_test_4 ON dbo.Users(AccountId) INCLUDE(UpVotes);
+/* Expected: One index should be kept with includes merged, other should be disabled */
+
+/* 3. Key duplicates, one unique */
+
+/* Test Case 3: Matching key columns, one unique */
+CREATE UNIQUE INDEX uq_test_1 ON dbo.Users(Location, Id);
+CREATE INDEX ix_test_5 ON dbo.Users(Location, Id) INCLUDE(LastAccessDate);
+/* Expected: Unique index should be kept, includes should be merged in */
+
+/* 4. Superset/subset key columns */
+
+/* Test Case 4: Superset/subset keys (not unique) */
+CREATE INDEX ix_test_6 ON dbo.Users(Age);
+CREATE INDEX ix_test_7 ON dbo.Users(Age, CreationDate);
+/* Expected: Wider index (Age, CreationDate) kept, narrower index disabled */
+
+/* 5. Superset/subset with uniqueness */
+
+/* Test Case 5: Superset/subset with unique narrower index */
+CREATE UNIQUE INDEX uq_test_2 ON dbo.Users(EmailHash, Id);
+CREATE INDEX ix_test_8 ON dbo.Users(EmailHash, Id, WebsiteUrl);
+/* Expected: Both kept (unique index should not be combined with wider non-unique index) */
+
+/* 6. Mismatched key orders */
+
+/* Test Case 6: Mismatched key orders */
+CREATE INDEX ix_test_9 ON dbo.Users(CreationDate, LastAccessDate, Views);
+CREATE INDEX ix_test_10 ON dbo.Users(CreationDate, Views, LastAccessDate);
+/*
+Expected: Marked as "Same Keys Different Order" for review (Rule 8). This shows
+up in the analysis report rows, not as a generated script, so look past the
+script output when checking this one.
+*/
+
+/* 7. Unique constraint vs. nonclustered index */
+
+/* Test Case 7a: Exact match between unique constraint and nonclustered index */
+ALTER TABLE dbo.Users ADD CONSTRAINT uq_test_c1 UNIQUE (DownVotes, Id);
+CREATE INDEX ix_c1 ON dbo.Users(DownVotes, Id) INCLUDE(AboutMe);
+/* Expected: Constraint disabled, index should be made unique */
+
+/* Test Case 7b: Unique constraint vs. wider nonclustered index (should NOT match) */
+ALTER TABLE dbo.Users ADD CONSTRAINT uq_test_c2 UNIQUE (UpVotes, Id);
+CREATE INDEX ix_c2 ON dbo.Users(UpVotes, Id, Reputation);
+/* Expected: No action - keys don't match exactly */
+
+/* Test Case 7c: Unique constraint wider than nonclustered index (should NOT match) */
+ALTER TABLE dbo.Users ADD CONSTRAINT uq_test_c3 UNIQUE (Id, DisplayName);
+CREATE INDEX ix_c3 ON dbo.Users(Id) INCLUDE(LastAccessDate);
+/* Expected: No action - keys don't match exactly */
+
+/* 8. Testing Edge Cases */
+
+/* Test Case 8a: Filtered indexes */
+CREATE INDEX ix_filtered_1 ON dbo.Users(Reputation) WHERE (Reputation > 1000);
+CREATE INDEX ix_filtered_2 ON dbo.Users(Reputation) WHERE (Reputation > 1000);
+/* Expected: One index kept, one disabled (matching filters) */
+
+/* Test Case 8b: Filtered indexes with different filters (should NOT match) */
+CREATE INDEX ix_filtered_3 ON dbo.Users(Reputation) WHERE (Reputation > 2000);
+/* Expected: Not matched with the above indexes due to different filter */
+
+/* Test Case 8c: Descending key sort orders */
+CREATE INDEX ix_desc_1 ON dbo.Users(Reputation DESC);
+CREATE INDEX ix_desc_2 ON dbo.Users(Reputation DESC);
+/* Expected: One kept, one disabled (matching sort directions) */
+
+/* Test Case 8d: Different sort directions (should NOT match) */
+CREATE INDEX ix_desc_3 ON dbo.Users(Reputation ASC);
+/* Expected: Not matched with above indexes due to different sort direction */
+GO
+
+/*
+Give every fixture index usage so none of them look never-used.
+
+The original working copy of this script drove reads with WHILE 1 = 1 loops meant
+to be cancelled by hand. That is fine at a keyboard and a trap in a repository,
+so this is bounded. Raise @iterations if a test needs bigger read counts - for
+example when exercising @min_reads, which needs indexes on both sides of the
+floor to be interesting.
+*/
+DECLARE
+ @c bigint,
+ @i integer = 0,
+ @iterations integer = 50;
+
+WHILE @i < @iterations
+BEGIN
+ /* Test Case 1: Exact duplicates */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_1) WHERE u.DisplayName LIKE 'A%' OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_2) WHERE u.DisplayName LIKE 'B%' OPTION(MAXDOP 1);
+
+ /* Test Case 2: Key duplicates with different includes */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_3) WHERE u.AccountId > 1000 OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_4) WHERE u.AccountId < 5000 OPTION(MAXDOP 1);
+
+ /* Test Case 3: Matching key columns, one unique */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_1) WHERE u.Location = 'New York' OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_5) WHERE u.Location LIKE 'San%' OPTION(MAXDOP 1);
+
+ /* Test Case 4: Superset/subset keys */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_6) WHERE u.Age = 30 OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_7) WHERE u.Age = 30 AND u.CreationDate > '20100101' OPTION(MAXDOP 1);
+
+ /* Test Case 5: Superset/subset with unique narrower index */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_2) WHERE u.EmailHash = 1 OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_8) WHERE u.EmailHash = 2 AND u.WebsiteUrl IS NOT NULL OPTION(MAXDOP 1);
+
+ /* Test Case 6: Mismatched key orders */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_9) WHERE u.CreationDate > '20100101' AND u.LastAccessDate < '20200101' OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_10) WHERE u.CreationDate > '20100101' AND u.Views > 1000 OPTION(MAXDOP 1);
+
+ /* Test Case 7a: Unique constraint and its exact-match nonclustered index */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_c1) WHERE u.DownVotes = 10 OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_c1) WHERE u.DownVotes = 20 OPTION(MAXDOP 1);
+
+ /* Test Case 7b: Unique constraint vs. wider nonclustered index */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_c2) WHERE u.UpVotes = 100 OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_c2) WHERE u.UpVotes = 200 AND u.Reputation > 1000 OPTION(MAXDOP 1);
+
+ /* Test Case 7c: Unique constraint wider than nonclustered index */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_c3) WHERE u.DisplayName = 'A' OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_c3) WHERE u.Id = 2 OPTION(MAXDOP 1);
+
+ /* Test Case 8a/8b: Filtered indexes */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_filtered_1) WHERE u.Reputation > 1000 OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_filtered_2) WHERE u.Reputation > 1000 OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_filtered_3) WHERE u.Reputation > 2000 OPTION(MAXDOP 1);
+
+ /* Test Case 8c/8d: Sort directions */
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_desc_1) OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_desc_2) OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_desc_3) OPTION(MAXDOP 1);
+
+ SELECT @i += 1;
+END;
+GO
+
+/* Now look at what the procedure makes of them */
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @dedupe_only = 1;
+GO
diff --git a/sp_IndexCleanup/tests/generate_index_reads.sql b/sp_IndexCleanup/tests/generate_index_reads.sql
new file mode 100644
index 00000000..812c547f
--- /dev/null
+++ b/sp_IndexCleanup/tests/generate_index_reads.sql
@@ -0,0 +1,172 @@
+/*
+sp_IndexCleanup Test Fixtures - Generate Index Usage
+====================================================
+Drives reads through each index built by fixtures_dupe_indexes.sql so
+sys.dm_db_index_usage_stats has something to report. Without this every index
+looks never-used and the usage-based rules and filters have nothing to work on.
+
+Each index gets one read per iteration, so after the default 100 iterations
+every hinted index carries ~100 reads and the object total is ~2000. That
+matters when testing @min_reads: the object-level screen sums usage across all
+indexes on the table, while the index-level screen compares each index on its
+own.
+
+IX_Users_Unused and IX_Users_Unused_WithIncludes are deliberately left out so
+the unused-index rules have something to find.
+
+Prerequisites: run fixtures_dupe_indexes.sql first.
+
+Runtime: roughly 2 minutes against StackOverflow2013 on a local instance.
+*/
+USE StackOverflow2013;
+GO
+
+DECLARE
+ @i integer = 0,
+ @c bigint = 0;
+
+WHILE
+ @i < 100
+BEGIN
+ /* Generate reads for IX_Users_Reputation */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Reputation)
+ WHERE Reputation > 1000
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_Reputation_ASC */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Reputation_ASC)
+ WHERE Reputation < 1000
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_DisplayName_Reputation */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_DisplayName_Reputation)
+ WHERE DisplayName LIKE 'A%' AND Reputation > 5000
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_DisplayName */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_DisplayName)
+ WHERE DisplayName LIKE 'B%'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_CreationDate */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_CreationDate)
+ WHERE CreationDate > '20100101'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_CreationDate_DisplayName */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_CreationDate_DisplayName)
+ WHERE CreationDate > '20120101'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for UQ_Users_EmailHash */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = UQ_Users_EmailHash)
+ WHERE EmailHash = 123456
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_Reputation_HighRep */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Reputation_HighRep)
+ WHERE Reputation > 50000
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_Reputation_MediumRep */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Reputation_MediumRep)
+ WHERE Reputation = 5000
+ OPTION (MAXDOP 1);
+
+ /*
+ Generate reads for IX_Users_LastAccessDate. StackOverflow2013 data stops in
+ 2013, so these later date predicates match nothing - that is fine, the seek
+ still registers as a read, which is all this needs.
+ */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_LastAccessDate)
+ WHERE LastAccessDate > '20200101'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_LastAccessDate_Extended */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_LastAccessDate_Extended)
+ WHERE LastAccessDate > '20210101'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_Location_Age */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Location_Age)
+ WHERE Location LIKE '%United States%' AND Age > 30
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_AccountId_DisplayName */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_AccountId_DisplayName)
+ WHERE AccountId = 1234 AND DisplayName LIKE 'C%'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_AccountId_Reputation */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_AccountId_Reputation)
+ WHERE AccountId = 5678
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_Age_Location */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Age_Location)
+ WHERE Age > 25 AND Location LIKE 'S%'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_Reputation_CreationDate */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Reputation_CreationDate)
+ WHERE Reputation > 5000 AND CreationDate > '20190101'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_LastAccessDate_Full */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_LastAccessDate_Full)
+ WHERE LastAccessDate > '20230101'
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_DisplayName_RepAge */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_DisplayName_RepAge)
+ WHERE DisplayName LIKE 'D%' AND Age > 40
+ OPTION (MAXDOP 1);
+
+ /* Generate reads for IX_Users_Reputation_VeryHighRep */
+ SELECT @c = COUNT_BIG(*)
+ FROM dbo.Users WITH (INDEX = IX_Users_Reputation_VeryHighRep)
+ WHERE Reputation > 75000
+ OPTION (MAXDOP 1);
+
+ SELECT @i += 1;
+
+ IF @i % 10 = 0
+ BEGIN
+ RAISERROR('iteration %d', 0, 0, @i) WITH NOWAIT;
+ END;
+END;
+GO
+
+/* Confirm the indexes actually accumulated usage */
+SELECT
+ index_name = i.name,
+ reads = ius.user_seeks + ius.user_scans + ius.user_lookups,
+ writes = ius.user_updates
+FROM sys.indexes AS i
+LEFT JOIN sys.dm_db_index_usage_stats AS ius
+ ON ius.object_id = i.object_id
+ AND ius.index_id = i.index_id
+ AND ius.database_id = DB_ID()
+WHERE i.object_id = OBJECT_ID(N'dbo.Users')
+AND i.index_id > 1
+ORDER BY
+ reads DESC;
+GO
diff --git a/sp_IndexCleanup/tests/manual_test_runs.sql b/sp_IndexCleanup/tests/manual_test_runs.sql
new file mode 100644
index 00000000..07259d7f
--- /dev/null
+++ b/sp_IndexCleanup/tests/manual_test_runs.sql
@@ -0,0 +1,76 @@
+/*
+sp_IndexCleanup Manual Test Runs
+=================================
+Assorted ways to invoke the procedure once fixtures are in place. These are for
+eyeballing output, not assertions - the automated checks live in run_tests.py.
+
+Prerequisites:
+ 1. fixtures_dupe_indexes.sql
+ 2. generate_index_reads.sql
+
+Then run these individually rather than as one batch: most produce large result
+sets, and @debug = 1 produces a lot more.
+*/
+
+/* Basic execution for a database */
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @debug = 1;
+
+/* Target a specific schema and table */
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @schema_name = 'dbo',
+ @table_name = 'Users',
+ @debug = 0;
+
+/*
+Only consider indexes with minimum usage and size.
+
+Both floors are applied twice: once per object (a table qualifies when its
+indexes together clear a floor) and once per index (an individual index is
+dropped from the analysis when it misses on its own). An index counts as used
+when it clears EITHER floor.
+
+With generate_index_reads.sql's default 100 iterations each index carries ~100
+reads, so @min_reads = 1000 screens all of them out and the run comes back
+empty. Lower the floor or raise the iteration count to see it filter partially,
+which is the interesting case.
+*/
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @min_reads = 1000, /* Only analyze indexes with at least 1000 reads */
+ @min_writes = 500, /* Only analyze indexes with at least 500 writes */
+ @min_size_gb = 0.01, /* Only analyze indexes at least 10MB in size */
+ @min_rows = 10000, /* Only analyze tables with at least 10,000 rows */
+ @debug = 0;
+
+/* Same filters, with the diagnostic output */
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @min_reads = 1000,
+ @min_writes = 500,
+ @min_size_gb = 0.01,
+ @min_rows = 10000,
+ @debug = 1;
+
+/* A floor low enough to keep some indexes and drop others */
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @min_reads = 50,
+ @debug = 0;
+
+/* Deduplication rules only, skipping compression analysis */
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @dedupe_only = 1,
+ @debug = 0;
+
+/* Production run without debug output */
+EXECUTE dbo.sp_IndexCleanup
+ @database_name = 'StackOverflow2013',
+ @debug = 0;
+
+/* View help documentation */
+EXECUTE dbo.sp_IndexCleanup
+ @help = 1;
diff --git a/sp_IndexCleanup/tests/run_tests.py b/sp_IndexCleanup/tests/run_tests.py
index 643b9a86..a4db5a11 100644
--- a/sp_IndexCleanup/tests/run_tests.py
+++ b/sp_IndexCleanup/tests/run_tests.py
@@ -93,18 +93,35 @@ def assert_test(group, name, condition, detail=""):
assert_test("1-UC", "1a: NC subset of UC flagged DISABLE",
len(matches) == 1, f"found {len(matches)}")
- # 1a: UC merge script → CREATE UNIQUE
+ # 1a: A unique CONSTRAINT must NOT be picked as the wider merge target.
+ #
+ # These two assertions used to expect the opposite, and the proc used to
+ # oblige, emitting:
+ # CREATE UNIQUE INDEX [uq_uc_abc] ... INCLUDE ([col_e])
+ # WITH (DROP_EXISTING = ON, ...)
+ # against a constraint-backed index. SQL Server rejects that outright with
+ # Msg 1907 ("The new index definition does not match the constraint being
+ # enforced by the existing index") - verified on 2016/2019/2022/2025. The
+ # paired DISABLE of the subset ran fine, so the net effect was losing a
+ # covering index while the constraint never absorbed its include.
+ #
+ # Contrast with 1d below: a plain unique INDEX does accept DROP_EXISTING
+ # with an added INCLUDE, so it remains a valid merge target. The dividing
+ # line is is_unique_constraint, not is_unique.
matches = find_rows(rows, table_name="test_ic_uc", index_name="uq_uc_abc",
script_type="MERGE SCRIPT")
- has_unique = any("CREATE UNIQUE" in m.get("script", "") for m in matches)
- assert_test("1-UC", "1a: UC merge script has CREATE UNIQUE",
- has_unique, f"found {len(matches)} merge rows, unique={has_unique}")
-
- # 1b: NC subset of UC with includes → DISABLE
+ assert_test("1-UC", "1a: UC NOT used as merge target (Msg 1907)",
+ len(matches) == 0, f"found {len(matches)} merge rows (expected 0)")
+
+ # 1b: A subset carrying an include the constraint cannot absorb must survive.
+ # Disabling it would drop col_e coverage with no working merge to replace it.
+ # (ix_uc_ab, which has no includes, is still correctly disabled above: the
+ # constraint's keys already cover it, so no script against the constraint is
+ # needed.)
matches = find_rows(rows, table_name="test_ic_uc", index_name="ix_uc_ab_inc",
script_type="DISABLE SCRIPT")
- assert_test("1-UC", "1b: NC subset of UC (with includes) flagged DISABLE",
- len(matches) == 1, f"found {len(matches)}")
+ assert_test("1-UC", "1b: NC subset of UC (with includes) NOT disabled",
+ len(matches) == 0, f"found {len(matches)} (expected 0)")
# 1c: NC with non-prefix UC keys → NOT subset
matches = find_rows(rows, table_name="test_ic_uc", index_name="ix_uc_bc",
From 11b3373930bd6cda4e510f7a6a784eeb91cdf7fd Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 16 Jul 2026 19:56:07 -0400
Subject: [PATCH 17/60] sp_IndexCleanup: source merged includes from
#index_details; assert the rules
Merged include lists were built by splitting included_columns apart on ', ' and
reassembling the fragments. A column name may legally contain a comma and a
space, so that delimiter was ambiguous: [Last, First] tore into [Last and
First], and the DISTINCT sort could then slot another column between the halves.
CREATE TABLE dbo.t (col_a int, col_b int, [Last, First] int, [zzz] int);
CREATE UNIQUE INDEX uq ON dbo.t (col_a) INCLUDE (col_b);
CREATE INDEX ix ON dbo.t (col_a) INCLUDE ([Last, First], [zzz]);
emitted INCLUDE ([col_b], [Last, [zzz], First]), which fails with Msg 102 while
the paired DISABLE of the loser still runs -- the same silent include loss as
the Msg 1907, 1911, 9411 and 103 defects fixed previously.
All four merge sites (Rule 6, Rule 7.6, and both Key Duplicate paths) now read
#index_details, which already holds one row per column, instead of parsing a
delimited string. There is nothing left to split, so the entity escaping and the
widened XML extractions those sites needed are gone with it -- the delimiter,
Msg 9411 and Msg 103 bug classes are retired rather than patched. Excluding a
column already in the superset's key is now an exact column-name match rather
than a CHARINDEX search of the key_columns string.
Output is byte-identical to the previous build across all eight invocations of
the capture suite against the real StackOverflow2013.dbo.Users fixtures. The
only behavior change is that hostile column names now work.
Tests: two new harnesses covering ground nothing asserted before.
fixture_cases_test.py drives fixtures_more_dupe_indexes.sql and asserts the
Expected: comments -- cases 1 through 8d -- and then EXECUTES every generated
MERGE and DISABLE script inside a rolled-back transaction. That execute check is
the point: every defect found in this procedure has been a script that reads
correctly and cannot run, which compiling and eyeballing both miss. Pointed at
the pre-fix build the suite goes red on exactly the historical bugs, with
Msg 1907 surfacing from the execute check alone. SET PARSEONLY ON would not have
caught it; 1907 is semantic, not syntactic.
rule_coverage_test.py covers unused-index detection and the @min_reads /
@min_writes index-level screen. sp_IndexCleanup auto-enables @dedupe_only when
uptime is <= 7 days, so Rule 1 is unreachable on a fresh instance; rather than
skip, the harness asserts whichever is true -- Rule 1 firing on a long-uptime
server, or the guard suppressing it on a fresh one -- after proving the index was
analyzed and has zero reads, so an absent Unused Index row means the guard rather
than an invisible index.
README documents both, the execute check and why it matters, and the uptime
guard, which makes unused-index detection look broken on a just-restarted server
when it is working exactly as designed.
Verified on 2016/2017/2019/2022/2025: compiles clean; run_tests.py 28/28;
fixture_cases_test.py 31/31; rule_coverage_test.py 11/11; no_access_test.py 4/4;
[Last, First], [A&B], [L
---
sp_IndexCleanup/sp_IndexCleanup.sql | 533 +++++++------------
sp_IndexCleanup/tests/README.md | 48 +-
sp_IndexCleanup/tests/fixture_cases_test.py | 536 +++++++++++++++++++
sp_IndexCleanup/tests/rule_coverage_test.py | 559 ++++++++++++++++++++
4 files changed, 1318 insertions(+), 358 deletions(-)
create mode 100644 sp_IndexCleanup/tests/fixture_cases_test.py
create mode 100644 sp_IndexCleanup/tests/rule_coverage_test.py
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index 6f80f8fd..8d4e4478 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -3944,83 +3944,62 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
superset.scope_hash,
superset.index_name,
superset.key_columns,
+ /*
+ Merge the superset's own includes with those of every subset it
+ supersedes.
+
+ This reads #index_details, one row per column, instead of splitting the
+ included_columns strings on ', '. A column name may legally contain a
+ comma and a space, so that delimiter could tear [Last, First] into two
+ fragments and the DISTINCT sort could then slot another column between
+ them, emitting a broken INCLUDE list while the paired DISABLE still ran.
+ Reading the columns directly also removes the need to escape entities for
+ the XML round-trip.
+ */
merged_includes =
STUFF
(
(
- SELECT DISTINCT
+ SELECT
N', ' +
- t.c.value('.', 'nvarchar(258)')
- FROM
+ QUOTENAME(idc.column_name)
+ FROM #index_details AS idc
+ WHERE idc.is_included_column = 1
+ AND
+ (
+ /* The superset's own includes */
+ idc.index_hash = superset.index_hash
+ /* Plus those of every subset it supersedes */
+ OR EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_analysis AS subset
+ WHERE subset.scope_hash = superset.scope_hash
+ AND subset.target_index_name = superset.index_name
+ AND subset.action = N'DISABLE'
+ AND subset.consolidation_rule = N'Key Subset'
+ AND subset.index_hash = idc.index_hash
+ )
+ )
+ /*
+ A column already in the superset's key doesn't need
+ including. Matching column names exactly beats searching the
+ key_columns string for one.
+ */
+ AND NOT EXISTS
(
- /*
- Superset's own includes. Entities are escaped before the
- split or CONVERT(xml, ...) fails outright with Msg 9411
- on a legal column name like [A&B].
- */
- SELECT
- x = CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(superset.included_columns, N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- WHERE superset.included_columns IS NOT NULL
-
- UNION ALL
-
- /* ALL subsets' includes */
SELECT
- x = CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(subset.included_columns, N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- FROM #index_analysis AS subset
- WHERE subset.scope_hash = superset.scope_hash
- AND subset.target_index_name = superset.index_name
- AND subset.action = N'DISABLE'
- AND subset.consolidation_rule = N'Key Subset'
- AND subset.included_columns IS NOT NULL
- ) AS a
- CROSS APPLY a.x.nodes('/c') AS t(c)
- /* Filter out columns already in superset's key */
- WHERE CHARINDEX(t.c.value('.', 'nvarchar(258)'), superset.key_columns) = 0
- AND LEN(t.c.value('.', 'nvarchar(258)')) > 0
- /* TYPE plus .value() so the entities don't come back re-encoded */
+ 1/0
+ FROM #index_details AS idk
+ WHERE idk.index_hash = superset.index_hash
+ AND idk.is_included_column = 0
+ AND idk.column_name = idc.column_name
+ )
+ GROUP BY
+ idc.column_name
+ ORDER BY
+ idc.column_name
FOR
XML
PATH(''),
@@ -4510,94 +4489,60 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
UPDATE
ia_winner
SET
+ /*
+ Merge the disabled Key Duplicates' includes into the index being made
+ unique.
+
+ Sourced from #index_details, one row per column, rather than splitting
+ included_columns on ', '. A column name may legally contain a comma and
+ a space, which made that delimiter ambiguous: [Last, First] tore into two
+ fragments the DISTINCT sort could separate, producing a broken INCLUDE
+ list on a script whose paired DISABLE still ran.
+
+ Winner and losers are key duplicates, so their keys match and SQL Server
+ would not allow a key column to also be an include. No key exclusion is
+ needed.
+ */
ia_winner.included_columns =
- (
- SELECT
- combined_cols =
- STUFF
+ STUFF
+ (
+ (
+ SELECT
+ N', ' +
+ QUOTENAME(idc.column_name)
+ FROM #index_details AS idc
+ WHERE idc.is_included_column = 1
+ AND
(
- (
- SELECT DISTINCT
- N', ' + t.c.value('.', 'nvarchar(258)')
- FROM
- (
- /*
- Winner's includes. Entities are escaped first or
- CONVERT(xml, ...) fails with Msg 9411 on a legal
- column name like [A&B].
- */
- SELECT
- x = CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(ISNULL(ia_winner.included_columns, N''), N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- WHERE ia_winner.included_columns IS NOT NULL
-
- UNION ALL
-
- /* Loser's includes */
- SELECT
- x = CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(ia_loser.included_columns, N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- FROM #index_analysis AS ia_loser
- WHERE ia_loser.scope_hash = ia_winner.scope_hash
- AND ia_loser.key_filter_hash = ia_winner.key_filter_hash
- AND ia_loser.action = N'DISABLE'
- AND ia_loser.consolidation_rule = N'Key Duplicate'
- AND ia_loser.target_index_name = ia_winner.index_name
- AND ia_loser.included_columns IS NOT NULL
- ) AS a
- CROSS APPLY a.x.nodes('/c') AS t(c)
- WHERE LEN(t.c.value('.', 'nvarchar(258)')) > 0
- /* TYPE plus .value() so the entities don't come back re-encoded */
- FOR
- XML
- PATH(''),
- TYPE
- ).value('text()[1]', 'nvarchar(max)'),
- 1,
- 2,
- ''
+ /* The winner's own includes */
+ idc.index_hash = ia_winner.index_hash
+ /* Plus those of every duplicate being disabled for it */
+ OR EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_analysis AS ia_loser
+ WHERE ia_loser.scope_hash = ia_winner.scope_hash
+ AND ia_loser.key_filter_hash = ia_winner.key_filter_hash
+ AND ia_loser.action = N'DISABLE'
+ AND ia_loser.consolidation_rule = N'Key Duplicate'
+ AND ia_loser.target_index_name = ia_winner.index_name
+ AND ia_loser.index_hash = idc.index_hash
+ )
)
- ),
+ GROUP BY
+ idc.column_name
+ ORDER BY
+ idc.column_name
+ FOR
+ XML
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
+ 1,
+ 2,
+ ''
+ ),
ia_winner.superseded_by =
CASE
WHEN ia_winner.superseded_by IS NULL
@@ -4998,124 +4943,64 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
RAISERROR('Merging included columns from Key Duplicate indexes', 0, 0) WITH NOWAIT;
END;
- WITH
- KeyDuplicateIncludes AS
- (
- SELECT
- winner.database_id,
- winner.object_id,
- winner.index_id,
- winner.index_name,
- winner.index_hash,
- winner.included_columns AS winner_includes,
- loser.included_columns AS loser_includes
- FROM #index_analysis AS winner
- JOIN #key_duplicate_dedupe AS kdd
- ON winner.scope_hash = kdd.scope_hash
- AND winner.key_filter_hash = kdd.key_filter_hash
- AND winner.index_name = kdd.winning_index_name
- JOIN #index_analysis AS loser
- ON loser.scope_hash = kdd.scope_hash
- AND loser.key_filter_hash = kdd.key_filter_hash
- AND loser.index_name <> kdd.winning_index_name
- AND loser.action = N'DISABLE'
- AND loser.consolidation_rule = N'Key Duplicate'
- WHERE winner.action = N'MERGE INCLUDES'
- AND winner.consolidation_rule = N'Key Duplicate'
- )
+ /*
+ Merge every disabled duplicate's includes into the winner.
+
+ This reads #index_details rather than splitting the winner's and losers'
+ included_columns strings apart on ', '. That delimiter is ambiguous: a column
+ name is allowed to contain a comma and a space, so [Last, First] used to tear
+ into [Last and First], and the DISTINCT sort could then slot another column
+ between the halves - emitting INCLUDE ([col_b], [Last, [zzz], First]), which
+ fails with Msg 102 while the paired DISABLE of the loser still runs.
+
+ #index_details already holds one row per column, so there is nothing to parse
+ and nothing to re-escape. Winner and losers share key_filter_hash, meaning
+ identical keys, and SQL Server will not let a key column also be an include,
+ so no key-column exclusion is needed here.
+ */
UPDATE
ia
SET
ia.included_columns =
- (
- SELECT
- /* Combine all includes from winner and all losers, removing duplicates */
- combined_cols =
- STUFF
+ STUFF
+ (
+ (
+ SELECT
+ N', ' +
+ QUOTENAME(idc.column_name)
+ FROM #index_details AS idc
+ WHERE idc.is_included_column = 1
+ AND
(
- (
- SELECT DISTINCT
- N', ' +
- t.c.value('.', 'nvarchar(258)')
- FROM
- (
- /*
- Create XML from winner's includes. Entities are
- escaped first or CONVERT(xml, ...) fails with
- Msg 9411 on a legal column name like [A&B].
- */
- SELECT
- x = CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(ISNULL(kdi.winner_includes, N''), N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- FROM KeyDuplicateIncludes AS kdi
- WHERE kdi.index_hash = ia.index_hash
- AND kdi.winner_includes IS NOT NULL
-
- UNION ALL
-
- /* Create XML from each loser's includes */
- SELECT
- x = CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(kdi.loser_includes, N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- FROM KeyDuplicateIncludes AS kdi
- WHERE kdi.index_hash = ia.index_hash
- AND kdi.loser_includes IS NOT NULL
- ) AS a
- /* Split XML into individual columns */
- CROSS APPLY a.x.nodes('/c') AS t(c)
- /* Filter out empty strings that can result from NULL handling */
- WHERE LEN(t.c.value('.', 'nvarchar(258)')) > 0
- /* TYPE plus .value() so the entities don't come back re-encoded */
- FOR
- XML
- PATH(''),
- TYPE
- ).value('text()[1]', 'nvarchar(max)'),
- 1,
- 2,
- ''
+ /* The winner's own includes */
+ idc.index_hash = ia.index_hash
+ /* Plus every duplicate it is about to disable */
+ OR EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_analysis AS loser
+ WHERE loser.scope_hash = ia.scope_hash
+ AND loser.key_filter_hash = ia.key_filter_hash
+ AND loser.index_name <> ia.index_name
+ AND loser.action = N'DISABLE'
+ AND loser.consolidation_rule = N'Key Duplicate'
+ AND loser.index_hash = idc.index_hash
+ )
)
- )
+ GROUP BY
+ idc.column_name
+ ORDER BY
+ idc.column_name
+ FOR
+ XML
+ PATH(''),
+ TYPE
+ ).value('text()[1]', 'nvarchar(max)'),
+ 1,
+ 2,
+ N''
+ )
FROM #index_analysis AS ia
WHERE ia.action = N'MERGE INCLUDES'
AND ia.consolidation_rule = N'Key Duplicate'
@@ -5186,6 +5071,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
that accepts CREATE UNIQUE INDEX ... INCLUDE (...) WITH (DROP_EXISTING = ON),
so the merge is safe to emit.
+ Includes come from #index_details, one row per column, rather than from
+ splitting included_columns on ', ' - a column name may legally contain a
+ comma and a space, which made that delimiter ambiguous.
+
Only rows whose merged list actually differs from what the winner already
covers are recorded. When a loser's includes are a subset of the winner's,
the existing KEEP plus the loser's DISABLE is already correct, and emitting a
@@ -5217,95 +5106,33 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
STUFF
(
(
- SELECT DISTINCT
+ SELECT
N', ' +
- t.c.value('.', 'nvarchar(258)')
- FROM
+ QUOTENAME(idc.column_name)
+ FROM #index_details AS idc
+ WHERE idc.is_included_column = 1
+ AND
(
/* The winner's own includes */
- SELECT
- x =
- CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(ia.included_columns, N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- WHERE ia.included_columns IS NOT NULL
-
- UNION ALL
-
- /* Every duplicate this winner is about to disable */
- SELECT
- x =
- CONVERT
- (
- xml,
- N'' +
- REPLACE
- (
- REPLACE
- (
- REPLACE
- (
- REPLACE(loser.included_columns, N'&', N'&'),
- N'<',
- N'<'
- ),
- N'>',
- N'>'
- ),
- N', ',
- N''
- ) +
- N''
- )
- FROM #index_analysis AS loser
- WHERE loser.scope_hash = ia.scope_hash
- AND loser.key_filter_hash = ia.key_filter_hash
- AND loser.target_index_name = ia.index_name
- AND loser.action = N'DISABLE'
- AND loser.consolidation_rule = N'Key Duplicate'
- AND loser.included_columns IS NOT NULL
- ) AS a
- CROSS APPLY a.x.nodes('/c') AS t(c)
- /*
- nvarchar(258), not sysname. A column name does max out at
- 128 characters, but these values are already wrapped by
- QUOTENAME, and QUOTENAME returns nvarchar(258): a
- 128-character name comes back 130 characters long, and
- pulling that through a sysname bucket lops off the closing
- bracket. The merge script then fails with Msg 103 while the
- paired DISABLE of the loser still runs.
- */
- /* A column already in the key doesn't need including */
- WHERE CHARINDEX(t.c.value('.', 'nvarchar(258)'), ia.key_columns) = 0
- AND LEN(t.c.value('.', 'nvarchar(258)')) > 0
- /*
- TYPE plus .value() on the way out is not optional here.
- A bare FOR XML PATH('') returns nvarchar and re-encodes
- entities, so a column named [A&B] would come back out as
- [A&B] and land in the generated script, which then
- fails with Msg 1911 (column does not exist) while the
- paired DISABLE of the loser still runs - the exact silent
- include loss this block exists to prevent.
- */
+ idc.index_hash = ia.index_hash
+ /* Plus every duplicate it is about to disable */
+ OR EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_analysis AS loser
+ WHERE loser.scope_hash = ia.scope_hash
+ AND loser.key_filter_hash = ia.key_filter_hash
+ AND loser.target_index_name = ia.index_name
+ AND loser.action = N'DISABLE'
+ AND loser.consolidation_rule = N'Key Duplicate'
+ AND loser.index_hash = idc.index_hash
+ )
+ )
+ GROUP BY
+ idc.column_name
+ ORDER BY
+ idc.column_name
FOR
XML
PATH(''),
diff --git a/sp_IndexCleanup/tests/README.md b/sp_IndexCleanup/tests/README.md
index a6379f46..20c18194 100644
--- a/sp_IndexCleanup/tests/README.md
+++ b/sp_IndexCleanup/tests/README.md
@@ -14,16 +14,54 @@ script at all -- the paired `DISABLE` still runs.
| --- | --- |
| `run_tests.py` | Runs `adversarial_test.sql`, parses the output, asserts 28 expectations across 12 rule groups. |
| `adversarial_test.sql` | Builds synthetic `test_ic_*` tables covering unique constraints, sort directions, filters, include merges, indexed views, heaps, and rule interactions. Self-cleaning. Driven by `run_tests.py`; run it directly only to eyeball output. |
+| `fixture_cases_test.py` | Drives `fixtures_more_dupe_indexes.sql` and asserts the `Expected:` comments in it -- cases 1 through 8d -- against the real `Users` table. **Also executes every generated MERGE/DISABLE script** inside a rolled-back transaction. ~2 minutes. |
+| `rule_coverage_test.py` | Small synthetic fixture in `Crap`. Covers unused-index detection (or the uptime guard, see below) and the `@min_reads`/`@min_writes` index-level screen. Fast. |
| `no_access_test.py` | Creates a login with no user-database access and asserts the `HAS_DBACCESS` preflight returns instead of spinning at 100% CPU forever (PerformanceMonitor #915). Drops the login afterward. |
```
cd sp_IndexCleanup/tests
python run_tests.py --server SQL2022
+python fixture_cases_test.py --server SQL2022
+python rule_coverage_test.py --server SQL2022
python no_access_test.py --server SQL2022
```
-Both take `--server` and `--password` (default `SQL2022` / the standard local sa
-password). Expect `28 passed` and `4 passed`.
+All take `--server` and `--password` (default `SQL2022` / the standard local sa
+password). Expect `28`, `31`, `11`, and `4` passed.
+
+### The execute check is the one that earns its keep
+
+`fixture_cases_test.py` runs every generated script for real, each in its own
+`BEGIN TRANSACTION ... ROLLBACK`. That is not belt-and-braces -- it is the check
+that catches this procedure's characteristic bug.
+
+Every defect found in it so far has had the same shape: **a merge script that
+cannot execute, paired with a `DISABLE` that can**, so the loser's covering
+column disappears with nothing absorbing it. Compiling does not catch it. Reading
+the output does not catch it. Only running the script does.
+
+Pointed at the build from before those fixes, this suite goes red on exactly
+those bugs, including `Msg 1907 - Cannot recreate index ... does not match the
+constraint being enforced by the existing index` surfacing purely from the
+execute check. `SET PARSEONLY ON` would sail straight past it: 1907 is a semantic
+error, not a syntax one.
+
+### Uptime guard: unused-index detection cannot run on a fresh instance
+
+`sp_IndexCleanup` auto-enables `@dedupe_only` when server uptime is <= 7 days,
+which skips Rule 1 entirely. That is deliberate -- usage stats are meaningless on
+a just-restarted server -- and it is not overridable by parameter.
+
+The practical consequence: **on a freshly restarted instance, unused-index
+detection will look broken when it is not.** Run with `@debug = 1` and you will
+see the procedure say so: `Server uptime is less than 7 days. Automatically
+enabling @dedupe_only mode.` Check that before concluding Rule 1 is broken.
+
+`rule_coverage_test.py` handles this by asserting whichever is true: on a
+long-uptime instance it asserts Rule 1 flags the unused index; on a fresh one it
+asserts the guard fired and suppressed it. It never silently skips, and it proves
+the index was analyzed and genuinely has zero reads first, so the absence of an
+`Unused Index` row means the guard, not an invisible index.
These are **not** wired into CI. `.github/workflows/sql-tests.yml` only runs
basic-execution and help-output smoke tests, which is why behavioral regressions
@@ -75,9 +113,9 @@ cleanly, and that the automated suite did not cover:
vanished. A plain unique *index* does accept `DROP_EXISTING` with an added
`INCLUDE` (unlike a constraint), so the merge is legal and now happens.
-Both were found by diffing output against these comments by hand. Nothing here
-asserts them automatically -- **wiring these cases into `run_tests.py` is the
-most valuable improvement available to this directory.**
+Both were found by diffing output against these comments by hand. They are now
+asserted automatically by `fixture_cases_test.py`, so a regression against either
+fails a test rather than waiting to be noticed.
## Verifying a change
diff --git a/sp_IndexCleanup/tests/fixture_cases_test.py b/sp_IndexCleanup/tests/fixture_cases_test.py
new file mode 100644
index 00000000..d2f2d886
--- /dev/null
+++ b/sp_IndexCleanup/tests/fixture_cases_test.py
@@ -0,0 +1,536 @@
+"""
+sp_IndexCleanup Fixture Case Assertions
+=======================================
+Drives fixtures_more_dupe_indexes.sql and asserts the "Expected:" comments in it.
+Those comments are the specification: two of them caught bugs that shipped, that
+compiled cleanly, and that the adversarial suite did not cover. Until now nothing
+asserted them automatically -- the README called wiring them up "the most
+valuable improvement available to this directory". This is that.
+
+The fixture is self-contained: it drops every nonclustered index in the database,
+builds cases 1 through 8d on StackOverflow2013.dbo.Users, drives reads through
+each one, and ends by running the procedure with @dedupe_only = 1. It takes about
+two minutes, most of it in the read-generation loop.
+
+HOW GENERATED SCRIPTS ARE VALIDATED: they are EXECUTED, not parsed.
+
+Every MERGE SCRIPT, DISABLE SCRIPT, and DISABLE CONSTRAINT SCRIPT in the output
+is run for real, each inside its own BEGIN TRANSACTION ... ROLLBACK, via
+sp_executesql inside TRY/CATCH so that a compile error surfaces as a catchable
+error rather than killing the batch. Each script is rolled back individually, so
+database state is identical before and after and the captured output stays valid.
+
+This matters, and SET PARSEONLY ON would not do. Every bug found in this
+procedure so far had the same shape: a script that reads correctly and parses
+correctly but cannot execute. The canonical example is case 7c, where the
+procedure emitted a merge into a unique constraint and SQL Server rejected it
+with Msg 1907 ("The new index definition does not match the constraint being
+enforced by the existing index") -- a semantic error that PARSEONLY sails right
+past, while the paired DISABLE ran fine and quietly cost a covering index.
+Executing is the only check that tells the two apart.
+
+Cleanup: drops the three unique constraints (uq_test_c1/c2/c3 -- DropIndexes does
+not reliably remove constraints) and runs StackOverflow2013.dbo.DropIndexes.
+
+Prerequisites:
+ - A scratch StackOverflow2013 (the fixture drops every nonclustered index).
+ - A dbo.DropIndexes helper procedure in that database.
+ - sp_IndexCleanup installed in master.
+
+Usage:
+ python fixture_cases_test.py [--server SQL2022] [--password "L!nt0044"]
+"""
+
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+FIXTURE_FILE = os.path.join(HERE, "fixtures_more_dupe_indexes.sql")
+
+# Rows that represent a dedupe action. A COMPRESSION SCRIPT row is not one of
+# these: an index that gets no dedupe action still gets compression advice, and
+# that is expected, so "no dedupe action" means none of these three.
+DEDUPE_SCRIPT_TYPES = ("DISABLE SCRIPT", "MERGE SCRIPT", "DISABLE CONSTRAINT SCRIPT")
+
+
+def run_sqlcmd(server, password, input_file=None, query=None,
+ database="StackOverflow2013", timeout=600):
+ """Run SQL from a file or a query string and capture output."""
+ cmd = [
+ "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ "-d", database,
+ "-W", # trim trailing spaces
+ "-s", "\t", # tab delimiter
+ ]
+ if input_file is not None:
+ cmd += ["-i", input_file]
+ else:
+ cmd += ["-Q", query]
+
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ return result.stdout, result.stderr
+
+
+def parse_output(stdout):
+ """
+ Parse sp_IndexCleanup tab-delimited output into rows.
+
+ Bounded to the script result set. The procedure emits several result sets and
+ the ones after this share nothing but a tab delimiter, so parsing stops at the
+ blank line that ends this one. Without that bound the summary rows get zipped
+ against these headers and land in the same list, which matters here because
+ several assertions below are assertions of absence.
+
+ Column order, verified empirically against the running procedure:
+ script_type, additional_info, database_name, schema_name, table_name,
+ index_name, consolidation_rule, target_index_name, superseded_info,
+ index_size_gb, index_rows, index_reads, index_writes,
+ original_index_definition, script
+ """
+ rows = []
+ lines = stdout.split("\n")
+ headers = None
+ started = False
+
+ for line in lines:
+ if headers is None:
+ if "script_type" in line and "index_name" in line:
+ headers = [h.strip() for h in line.split("\t")]
+ continue
+
+ if line.startswith("---"):
+ continue
+
+ if not line.strip():
+ # Blank line ends the result set once rows have started.
+ if started:
+ break
+ continue
+
+ cols = [c.strip() for c in line.split("\t")]
+ if len(cols) >= len(headers):
+ rows.append(dict(zip(headers, cols)))
+ started = True
+
+ return rows
+
+
+def find_rows(rows, **filters):
+ """Find rows matching all filter criteria."""
+ matches = []
+ for row in rows:
+ match = True
+ for key, value in filters.items():
+ if key.endswith("__like"):
+ col = key[:-6]
+ if col not in row or value.lower() not in row[col].lower():
+ match = False
+ break
+ elif key.endswith("__in"):
+ col = key[:-4]
+ if col not in row or row[col] not in value:
+ match = False
+ break
+ else:
+ if key not in row or row[key] != value:
+ match = False
+ break
+ if match:
+ matches.append(row)
+ return matches
+
+
+def dedupe_rows(rows, index_name):
+ """Every dedupe-action row for an index. Compression rows are not included."""
+ return [
+ r for r in rows
+ if r.get("index_name") == index_name
+ and r.get("script_type") in DEDUPE_SCRIPT_TYPES
+ ]
+
+
+def include_columns(script):
+ """The contents of a script's INCLUDE (...) clause, or '' if it has none."""
+ match = re.search(r"INCLUDE\s*\(([^)]*)\)", script or "", re.IGNORECASE)
+ return match.group(1) if match else ""
+
+
+def script_rows(rows):
+ """Every generated script row worth executing, in output order."""
+ seen = []
+ for r in rows:
+ if r.get("script_type") not in DEDUPE_SCRIPT_TYPES:
+ continue
+ script = r.get("script", "")
+ if not script or script == "NULL":
+ continue
+ seen.append(r)
+ return seen
+
+
+def validate_scripts(server, password, rows):
+ """
+ Execute every generated script inside its own rolled-back transaction.
+
+ sp_executesql inside TRY/CATCH, so a compile error in a generated script is
+ catchable instead of aborting the batch. Returns a list of
+ (index_name, script_type, error_or_None).
+ """
+ targets = script_rows(rows)
+ if not targets:
+ return []
+
+ batches = [
+ "SET NOCOUNT ON;",
+ "SET XACT_ABORT OFF;",
+ ]
+ for i, r in enumerate(targets):
+ escaped = r["script"].replace("'", "''")
+ tag = "SCRIPTCHECK~%d~" % i
+ batches.append(
+ "BEGIN TRY\n"
+ " BEGIN TRANSACTION;\n"
+ " EXECUTE sys.sp_executesql N'" + escaped + "';\n"
+ " IF @@TRANCOUNT > 0 ROLLBACK;\n"
+ " SELECT r = CONVERT(varchar(1000), '" + tag + "OK');\n"
+ "END TRY\n"
+ "BEGIN CATCH\n"
+ " IF @@TRANCOUNT > 0 ROLLBACK;\n"
+ " SELECT r = CONVERT(varchar(1000), '" + tag + "ERROR: Msg ' + "
+ "CONVERT(varchar(10), ERROR_NUMBER()) + ' - ' + ERROR_MESSAGE());\n"
+ "END CATCH;"
+ )
+
+ path = None
+ try:
+ handle, path = tempfile.mkstemp(suffix=".sql", prefix="ic_scriptcheck_")
+ with os.fdopen(handle, "w") as f:
+ f.write("\nGO\n".join(batches) + "\nGO\n")
+
+ stdout, _ = run_sqlcmd(server, password, input_file=path, timeout=600)
+ finally:
+ if path and os.path.exists(path):
+ os.remove(path)
+
+ results = {}
+ for line in stdout.split("\n"):
+ line = line.strip()
+ if not line.startswith("SCRIPTCHECK~"):
+ continue
+ _, index, outcome = line.split("~", 2)
+ results[int(index)] = outcome
+
+ out = []
+ for i, r in enumerate(targets):
+ outcome = results.get(i)
+ if outcome is None:
+ error = "no result captured for this script"
+ elif outcome == "OK":
+ error = None
+ else:
+ error = outcome
+ out.append((r["index_name"], r["script_type"], error))
+ return out
+
+
+def run_tests(rows, script_results):
+ """Run all assertions and return results."""
+ results = []
+
+ def assert_test(group, name, condition, detail=""):
+ results.append({
+ "group": group,
+ "name": name,
+ "passed": condition,
+ "detail": detail,
+ })
+
+ # ---- Case 1: Exact duplicates (same keys, same includes) ----
+ # ix_test_1 / ix_test_2 on (DisplayName) INCLUDE (Reputation).
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_test_2",
+ script_type="DISABLE SCRIPT")
+ assert_test("1-Exact-Dup", "ix_test_2 disabled (exact duplicate of ix_test_1)",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # ---- Case 2: Key duplicates with different includes ----
+ # ix_test_3 INCLUDE (Reputation) / ix_test_4 INCLUDE (UpVotes) on (AccountId).
+ # Expected: one kept with includes merged, other disabled.
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_test_4",
+ script_type="DISABLE SCRIPT")
+ assert_test("2-Key-Dup", "ix_test_4 disabled (key duplicate of ix_test_3)",
+ len(matches) == 1, "found %d" % len(matches))
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_test_3",
+ script_type="MERGE SCRIPT")
+ includes = include_columns(matches[0]["script"]) if matches else ""
+ merged = ("Reputation" in includes) and ("UpVotes" in includes)
+ assert_test("2-Key-Dup", "ix_test_3 merged, INCLUDE has both Reputation and UpVotes",
+ len(matches) == 1 and merged,
+ "found %d merge rows, INCLUDE=(%s)" % (len(matches), includes))
+
+ # ---- Case 3: Key duplicates, one a unique INDEX ----
+ # uq_test_1 UNIQUE (Location, Id) / ix_test_5 (Location, Id) INCLUDE
+ # (LastAccessDate). Expected: unique index kept, includes merged in.
+ #
+ # This is one of the two cases that caught a shipped bug: ix_test_5 used to be
+ # disabled with uq_test_1 as its target while nothing merged the include into
+ # uq_test_1, silently dropping LastAccessDate coverage. Unlike a constraint, a
+ # plain unique index does accept DROP_EXISTING with an added INCLUDE, so the
+ # merge is legal and must happen.
+
+ matches = find_rows(rows, table_name="Users", index_name="uq_test_1",
+ script_type="MERGE SCRIPT")
+ script = matches[0]["script"] if matches else ""
+ assert_test("3-Unique-Index", "uq_test_1 merged and script has CREATE UNIQUE",
+ len(matches) == 1 and "CREATE UNIQUE" in script,
+ "found %d merge rows, CREATE UNIQUE=%s"
+ % (len(matches), "CREATE UNIQUE" in script))
+
+ includes = include_columns(script)
+ assert_test("3-Unique-Index", "uq_test_1 INCLUDE absorbed LastAccessDate",
+ "LastAccessDate" in includes, "INCLUDE=(%s)" % includes)
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_test_5",
+ script_type="DISABLE SCRIPT")
+ assert_test("3-Unique-Index", "ix_test_5 disabled (its include went to uq_test_1)",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # ---- Case 4: Superset/subset keys, neither unique ----
+ # ix_test_6 (Age) is a subset of ix_test_7 (Age, CreationDate).
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_test_6",
+ script_type="DISABLE SCRIPT")
+ assert_test("4-Key-Subset", "ix_test_6 disabled (subset of ix_test_7)",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # ---- Case 5: Superset/subset with a unique narrower index ----
+ # uq_test_2 UNIQUE (EmailHash, Id) / ix_test_8 (EmailHash, Id, WebsiteUrl).
+ # Expected: both kept. A unique index must not be folded into a wider
+ # non-unique one -- the wider index cannot enforce the uniqueness.
+
+ matches = dedupe_rows(rows, "uq_test_2")
+ assert_test("5-Unique-Subset", "uq_test_2 gets no dedupe action",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ matches = dedupe_rows(rows, "ix_test_8")
+ assert_test("5-Unique-Subset", "ix_test_8 gets no dedupe action",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ # ---- Case 6: Mismatched key orders (Rule 8) ----
+ # ix_test_9 (CreationDate, LastAccessDate, Views) vs
+ # ix_test_10 (CreationDate, Views, LastAccessDate).
+ # Expected: flagged "Same Keys Different Order" for review. This surfaces as
+ # an analysis report row with script_type NEEDS REVIEW and a NULL script, not
+ # as generated DDL, so it is not a dedupe action and must not be one.
+
+ matches = [
+ r for r in rows
+ if r.get("index_name") in ("ix_test_9", "ix_test_10")
+ and r.get("script_type") == "NEEDS REVIEW"
+ and r.get("consolidation_rule") == "Same Keys Different Order"
+ ]
+ targets_each_other = all(
+ m.get("target_index_name") in ("ix_test_9", "ix_test_10") for m in matches
+ )
+ assert_test("6-Key-Order", "ix_test_9/ix_test_10 flagged Same Keys Different Order",
+ len(matches) >= 1 and targets_each_other,
+ "found %d review rows (%s)"
+ % (len(matches),
+ [(m["index_name"], m["target_index_name"]) for m in matches]))
+
+ # ---- Case 7a: Unique CONSTRAINT with an exact-match nonclustered index ----
+ # uq_test_c1 UNIQUE (DownVotes, Id) / ix_c1 (DownVotes, Id) INCLUDE (AboutMe).
+ # Expected: constraint dropped, index made unique to take over enforcement.
+
+ matches = find_rows(rows, table_name="Users", index_name="uq_test_c1",
+ script_type="DISABLE CONSTRAINT SCRIPT")
+ assert_test("7a-UC-Replace", "uq_test_c1 gets DISABLE CONSTRAINT",
+ len(matches) == 1, "found %d" % len(matches))
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_c1",
+ script_type="MERGE SCRIPT")
+ script = matches[0]["script"] if matches else ""
+ assert_test("7a-UC-Replace", "ix_c1 merged and script has CREATE UNIQUE",
+ len(matches) == 1 and "CREATE UNIQUE" in script,
+ "found %d merge rows, CREATE UNIQUE=%s"
+ % (len(matches), "CREATE UNIQUE" in script))
+
+ # ---- Case 7b: Unique constraint vs a WIDER nonclustered index ----
+ # uq_test_c2 UNIQUE (UpVotes, Id) / ix_c2 (UpVotes, Id, Reputation).
+ # Expected: no action, keys do not match exactly.
+
+ matches = dedupe_rows(rows, "uq_test_c2")
+ assert_test("7b-UC-Wider-NC", "uq_test_c2 gets no dedupe action",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ matches = dedupe_rows(rows, "ix_c2")
+ assert_test("7b-UC-Wider-NC", "ix_c2 gets no dedupe action",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ # ---- Case 7c: Unique constraint WIDER than the nonclustered index ----
+ # uq_test_c3 UNIQUE (Id, DisplayName) / ix_c3 (Id) INCLUDE (LastAccessDate).
+ # Expected: no action, keys do not match exactly.
+ #
+ # Load-bearing. The procedure used to emit a merge into the constraint --
+ # CREATE INDEX ... WITH (DROP_EXISTING = ON) against a constraint-backed
+ # index, which SQL Server rejects with Msg 1907 -- while still emitting a
+ # runnable DISABLE of ix_c3. The merge failed, the disable succeeded, and
+ # ix_c3's LastAccessDate coverage vanished with nothing absorbing it. The
+ # dividing line is is_unique_constraint, not is_unique: contrast case 3,
+ # where a plain unique index is a legal merge target.
+
+ matches = dedupe_rows(rows, "uq_test_c3")
+ assert_test("7c-UC-Wider", "uq_test_c3 gets no dedupe action (Msg 1907 merge)",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ matches = dedupe_rows(rows, "ix_c3")
+ assert_test("7c-UC-Wider", "ix_c3 gets no dedupe action (would lose LastAccessDate)",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ # ---- Case 8a: Filtered indexes with matching filters ----
+ # ix_filtered_1 / ix_filtered_2, both WHERE (Reputation > 1000).
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_filtered_2",
+ script_type="DISABLE SCRIPT")
+ assert_test("8a-Filter-Match", "ix_filtered_2 disabled (same filter as ix_filtered_1)",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # ---- Case 8b: Filtered index with a different filter ----
+ # ix_filtered_3 WHERE (Reputation > 2000). Covers different rows, keep it.
+
+ matches = dedupe_rows(rows, "ix_filtered_3")
+ assert_test("8b-Filter-Diff", "ix_filtered_3 gets no dedupe action (different filter)",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ # ---- Case 8c: Matching descending sort orders ----
+ # ix_desc_1 / ix_desc_2, both (Reputation DESC).
+
+ matches = find_rows(rows, table_name="Users", index_name="ix_desc_2",
+ script_type="DISABLE SCRIPT")
+ assert_test("8c-Sort-Match", "ix_desc_2 disabled (same sort as ix_desc_1)",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # ---- Case 8d: Different sort direction ----
+ # ix_desc_3 (Reputation ASC). Different ordering, different query patterns.
+
+ matches = dedupe_rows(rows, "ix_desc_3")
+ assert_test("8d-Sort-Diff", "ix_desc_3 gets no dedupe action (different sort)",
+ len(matches) == 0,
+ "found %d (%s)" % (len(matches), [m["script_type"] for m in matches]))
+
+ # ---- Every generated script must actually execute ----
+ # A script that reads correctly but cannot run is the bug this procedure keeps
+ # producing. Each was executed inside a rolled-back transaction.
+
+ for index_name, script_type, error in script_results:
+ assert_test("9-Executable", "%s (%s) executes" % (index_name, script_type),
+ error is None, error if error else "rolled back clean")
+
+ return results
+
+
+def cleanup(server, password):
+ """
+ Drop the three unique constraints and every nonclustered index the fixture
+ built. DropIndexes does not reliably remove constraints, so they go first and
+ explicitly.
+ """
+ query = """
+SET NOCOUNT ON;
+IF EXISTS (SELECT 1/0 FROM sys.key_constraints AS kc WHERE kc.name = N'uq_test_c1')
+BEGIN
+ ALTER TABLE dbo.Users DROP CONSTRAINT uq_test_c1;
+END;
+IF EXISTS (SELECT 1/0 FROM sys.key_constraints AS kc WHERE kc.name = N'uq_test_c2')
+BEGIN
+ ALTER TABLE dbo.Users DROP CONSTRAINT uq_test_c2;
+END;
+IF EXISTS (SELECT 1/0 FROM sys.key_constraints AS kc WHERE kc.name = N'uq_test_c3')
+BEGIN
+ ALTER TABLE dbo.Users DROP CONSTRAINT uq_test_c3;
+END;
+EXECUTE StackOverflow2013.dbo.DropIndexes;
+"""
+ run_sqlcmd(server, password, query=query, timeout=600)
+
+
+def main():
+ server = "SQL2022"
+ password = "L!nt0044"
+
+ # Parse args
+ args = sys.argv[1:]
+ for i, arg in enumerate(args):
+ if arg == "--server" and i + 1 < len(args):
+ server = args[i + 1]
+ elif arg == "--password" and i + 1 < len(args):
+ password = args[i + 1]
+
+ print("Running fixture case tests against %s..." % server)
+ print("Building fixtures and generating reads (about two minutes)...")
+ print()
+
+ try:
+ stdout, stderr = run_sqlcmd(server, password, input_file=FIXTURE_FILE,
+ timeout=600)
+
+ if "Msg " in stderr and "Level 16" in stderr:
+ print("ERROR: SQL errors detected:")
+ print(stderr)
+ sys.exit(1)
+
+ rows = parse_output(stdout)
+ print("Captured %d output rows from sp_IndexCleanup" % len(rows))
+
+ if len(rows) == 0:
+ print("ERROR: No output rows captured. Check SQL setup.")
+ print("stderr:", stderr[:500] if stderr else "(empty)")
+ sys.exit(1)
+
+ targets = script_rows(rows)
+ print("Executing %d generated scripts in rolled-back transactions..."
+ % len(targets))
+ print()
+ script_results = validate_scripts(server, password, rows)
+
+ results = run_tests(rows, script_results)
+ finally:
+ cleanup(server, password)
+
+ # Report
+ passed = sum(1 for r in results if r["passed"])
+ failed = sum(1 for r in results if not r["passed"])
+
+ for r in results:
+ status = "PASS" if r["passed"] else "FAIL"
+ print(" [%s] %s: %s (%s)" % (status, r["group"], r["name"], r["detail"]))
+
+ print()
+ print("Results: %d passed, %d failed, %d total" % (passed, failed, len(results)))
+
+ if failed > 0:
+ print()
+ print("FAILED TESTS:")
+ for r in results:
+ if not r["passed"]:
+ print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"]))
+ sys.exit(1)
+ else:
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sp_IndexCleanup/tests/rule_coverage_test.py b/sp_IndexCleanup/tests/rule_coverage_test.py
new file mode 100644
index 00000000..d9eb43d7
--- /dev/null
+++ b/sp_IndexCleanup/tests/rule_coverage_test.py
@@ -0,0 +1,559 @@
+"""
+sp_IndexCleanup Rule Coverage Tests
+===================================
+Covers two things nothing else in this directory tests: Rule 1 (unused index
+detection) and the @min_reads / @min_writes index-level screen.
+
+Builds its own synthetic fixture in the Crap database -- three ~20,000 row tables,
+scoped with @table_name so each procedure run is fast. It deliberately does not
+touch StackOverflow2013: fixture_cases_test.py already spends two minutes there,
+and this one is meant to be quick. Drops its tables afterward.
+
+(a) Rule 1, unused index detection.
+
+ The procedure auto-enables @dedupe_only when server uptime is <= 7 days, and
+ Rule 1 only runs when @dedupe_only = 0. So on a recently restarted instance
+ Rule 1 cannot be reached through the public interface at all. That is correct,
+ deliberate behavior: usage data on a freshly started instance is worthless,
+ and recommending index drops from it would be actively harmful.
+
+ Rather than skip, this asserts whichever statement is true on the instance in
+ front of it:
+
+ - uptime > 7 days: the never-read index gets a DISABLE with a consolidation
+ rule of 'Unused Index'.
+ - uptime <= 7 days: the GUARD holds. @dedupe_only was auto-enabled (proven
+ by the procedure's own @debug message) and no 'Unused
+ Index' rows appear anywhere.
+
+ The guard branch is a real assertion, not a silent pass. It is kept honest by
+ positive controls: the harness first proves the index was analyzed (it appears
+ in the output) and that it genuinely has zero reads, so the absence of an
+ 'Unused Index' row is the guard doing its job rather than the index being
+ invisible for some unrelated reason.
+
+(b) The @min_reads / @min_writes index-level screen.
+
+ The screen gates DEDUPE ONLY. It must not take compression recommendations
+ with it -- an index can be far too cold to bother deduping and still be worth
+ compressing. It is also an OR: an index clears by meeting EITHER floor.
+
+ The write-floor cases run against a table whose clustered PK carries reads
+ (an UPDATE seeks it) while ix_w1/ix_w2 have none. The table therefore clears
+ the object-level filter on its own, which means these cases exercise the
+ index-level screen specifically rather than the coarser per-table filter.
+
+Usage:
+ python rule_coverage_test.py [--server SQL2022] [--password "L!nt0044"]
+"""
+
+import os
+import subprocess
+import sys
+import tempfile
+
+
+TEST_DATABASE = "Crap"
+
+# Rows that represent a dedupe action. A COMPRESSION SCRIPT row is not one.
+DEDUPE_SCRIPT_TYPES = ("DISABLE SCRIPT", "MERGE SCRIPT", "DISABLE CONSTRAINT SCRIPT")
+
+GUARD_MESSAGE = "Automatically enabling @dedupe_only mode"
+
+SETUP_SQL = """
+SET NOCOUNT ON;
+
+DROP TABLE IF EXISTS dbo.ic_min_reads_test;
+DROP TABLE IF EXISTS dbo.ic_min_writes_test;
+DROP TABLE IF EXISTS dbo.ic_unused_test;
+GO
+
+/*
+ic_min_reads_test: ix_hot (~200 reads), ix_warm (~5 reads, a key duplicate of
+ix_hot with different includes so the two pair), clustered PK (0 reads).
+*/
+CREATE TABLE
+ dbo.ic_min_reads_test
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_b integer NOT NULL,
+ col_c integer NOT NULL,
+ filler varchar(100) NOT NULL,
+ CONSTRAINT pk_ic_min_reads_test PRIMARY KEY CLUSTERED (id)
+);
+
+/*
+ic_min_writes_test: ix_w1 / ix_w2 are a key-duplicate pair that is never read but
+is written to. The clustered PK picks up reads from the UPDATE seeks, which lets
+the table clear the object-level filter so the index-level screen is what gets
+tested.
+*/
+CREATE TABLE
+ dbo.ic_min_writes_test
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_b integer NOT NULL,
+ col_c integer NOT NULL,
+ filler varchar(100) NOT NULL,
+ CONSTRAINT pk_ic_min_writes_test PRIMARY KEY CLUSTERED (id)
+);
+
+/*
+ic_unused_test: ix_never_read has distinct keys so no dedupe rule can fire on it.
+The only thing that can flag it is Rule 1.
+*/
+CREATE TABLE
+ dbo.ic_unused_test
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_b integer NOT NULL,
+ filler varchar(100) NOT NULL,
+ CONSTRAINT pk_ic_unused_test PRIMARY KEY CLUSTERED (id)
+);
+GO
+
+INSERT INTO
+ dbo.ic_min_reads_test
+(
+ id,
+ col_a,
+ col_b,
+ col_c,
+ filler
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500,
+ col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100,
+ col_c = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 250,
+ filler = REPLICATE('x', 100)
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+INSERT INTO
+ dbo.ic_min_writes_test
+(
+ id,
+ col_a,
+ col_b,
+ col_c,
+ filler
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500,
+ col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100,
+ col_c = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 250,
+ filler = REPLICATE('x', 100)
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+INSERT INTO
+ dbo.ic_unused_test
+(
+ id,
+ col_a,
+ col_b,
+ filler
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500,
+ col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100,
+ filler = REPLICATE('x', 100)
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+GO
+
+CREATE INDEX ix_hot ON dbo.ic_min_reads_test (col_a) INCLUDE (col_b);
+CREATE INDEX ix_warm ON dbo.ic_min_reads_test (col_a) INCLUDE (col_c);
+
+CREATE INDEX ix_w1 ON dbo.ic_min_writes_test (col_a) INCLUDE (col_b);
+CREATE INDEX ix_w2 ON dbo.ic_min_writes_test (col_a) INCLUDE (col_c);
+
+CREATE INDEX ix_never_read ON dbo.ic_unused_test (col_a);
+GO
+
+/*
+Drive reads with forced index hints, bounded: ix_hot ~200, ix_warm ~5, PK 0.
+Nothing reads ic_unused_test at all.
+*/
+DECLARE
+ @c bigint,
+ @i integer = 0;
+
+WHILE @i < 200
+BEGIN
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_min_reads_test AS t WITH (INDEX = ix_hot) WHERE t.col_a = @i % 500 OPTION(MAXDOP 1);
+ SELECT @i += 1;
+END;
+
+SELECT @i = 0;
+
+WHILE @i < 5
+BEGIN
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_min_reads_test AS t WITH (INDEX = ix_warm) WHERE t.col_a = @i % 500 OPTION(MAXDOP 1);
+ SELECT @i += 1;
+END;
+GO
+
+/*
+Drive writes on ic_min_writes_test. Updating col_a maintains both ix_w1 and ix_w2
+(it is the key of both), so both collect writes while collecting no reads.
+*/
+DECLARE
+ @i integer = 0;
+
+WHILE @i < 60
+BEGIN
+ UPDATE
+ t
+ SET
+ t.col_a = (t.col_a + 1) % 500
+ FROM dbo.ic_min_writes_test AS t
+ WHERE t.id = @i
+ OPTION(MAXDOP 1);
+
+ SELECT @i += 1;
+END;
+GO
+"""
+
+CLEANUP_SQL = """
+SET NOCOUNT ON;
+DROP TABLE IF EXISTS dbo.ic_min_reads_test;
+DROP TABLE IF EXISTS dbo.ic_min_writes_test;
+DROP TABLE IF EXISTS dbo.ic_unused_test;
+"""
+
+
+def run_sqlcmd(server, password, input_file=None, query=None,
+ database=TEST_DATABASE, timeout=600):
+ """Run SQL from a file or a query string and capture output."""
+ cmd = [
+ "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ "-d", database,
+ "-W", # trim trailing spaces
+ "-s", "\t", # tab delimiter
+ ]
+ if input_file is not None:
+ cmd += ["-i", input_file]
+ else:
+ cmd += ["-Q", query]
+
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ return result.stdout, result.stderr
+
+
+def run_sql_script(server, password, sql, timeout=600):
+ """Run a multi-batch script (one containing GO) through a temp file."""
+ path = None
+ try:
+ handle, path = tempfile.mkstemp(suffix=".sql", prefix="ic_rule_cov_")
+ with os.fdopen(handle, "w") as f:
+ f.write(sql)
+ return run_sqlcmd(server, password, input_file=path, timeout=timeout)
+ finally:
+ if path and os.path.exists(path):
+ os.remove(path)
+
+
+def parse_output(stdout):
+ """
+ Parse sp_IndexCleanup tab-delimited output into rows.
+
+ Bounded to the script result set: the procedure emits several result sets and
+ the later ones share nothing but a tab delimiter, so parsing stops at the
+ blank line that ends this one. That bound matters here because most of the
+ assertions below are assertions of absence.
+
+ Column order, verified empirically against the running procedure:
+ script_type, additional_info, database_name, schema_name, table_name,
+ index_name, consolidation_rule, target_index_name, superseded_info,
+ index_size_gb, index_rows, index_reads, index_writes,
+ original_index_definition, script
+ """
+ rows = []
+ lines = stdout.split("\n")
+ headers = None
+ started = False
+
+ for line in lines:
+ if headers is None:
+ if "script_type" in line and "index_name" in line:
+ headers = [h.strip() for h in line.split("\t")]
+ continue
+
+ if line.startswith("---"):
+ continue
+
+ if not line.strip():
+ if started:
+ break
+ continue
+
+ cols = [c.strip() for c in line.split("\t")]
+ if len(cols) >= len(headers):
+ rows.append(dict(zip(headers, cols)))
+ started = True
+
+ return rows
+
+
+def find_rows(rows, **filters):
+ """Find rows matching all filter criteria."""
+ matches = []
+ for row in rows:
+ match = True
+ for key, value in filters.items():
+ if key.endswith("__like"):
+ col = key[:-6]
+ if col not in row or value.lower() not in row[col].lower():
+ match = False
+ break
+ elif key.endswith("__in"):
+ col = key[:-4]
+ if col not in row or row[col] not in value:
+ match = False
+ break
+ else:
+ if key not in row or row[key] != value:
+ match = False
+ break
+ if match:
+ matches.append(row)
+ return matches
+
+
+def dedupe_rows(rows, index_name):
+ """Every dedupe-action row for an index. Compression rows are not included."""
+ return [
+ r for r in rows
+ if r.get("index_name") == index_name
+ and r.get("script_type") in DEDUPE_SCRIPT_TYPES
+ ]
+
+
+def get_uptime_days(server, password):
+ """Server uptime in days, as the procedure itself computes it."""
+ stdout, _ = run_sqlcmd(
+ server, password, database="master",
+ query="SET NOCOUNT ON; SELECT uptime = DATEDIFF(DAY, osi.sqlserver_start_time,"
+ " SYSDATETIME()) FROM sys.dm_os_sys_info AS osi;",
+ )
+ for line in stdout.split("\n"):
+ line = line.strip()
+ if line.isdigit():
+ return int(line)
+ raise RuntimeError("Could not read server uptime:\n%s" % stdout)
+
+
+def run_proc(server, password, table_name, extra="", debug=False):
+ """Run sp_IndexCleanup scoped to one table and return (rows, stdout)."""
+ query = (
+ "EXECUTE master.dbo.sp_IndexCleanup "
+ "@database_name = '%s', @schema_name = 'dbo', @table_name = '%s'%s%s;"
+ % (TEST_DATABASE, table_name, extra, ", @debug = 1" if debug else "")
+ )
+ stdout, _ = run_sqlcmd(server, password, database="master", query=query)
+ return parse_output(stdout), stdout
+
+
+def run_tests(server, password, uptime_days):
+ """Run all assertions and return results."""
+ results = []
+
+ def assert_test(group, name, condition, detail=""):
+ results.append({
+ "group": group,
+ "name": name,
+ "passed": condition,
+ "detail": detail,
+ })
+
+ # ---- Group A: Rule 1, unused index detection ----
+
+ rows, _ = run_proc(server, password, "ic_unused_test")
+ unused_rows = [
+ r for r in rows
+ if "unused index" in (r.get("consolidation_rule") or "").lower()
+ ]
+
+ if uptime_days > 7:
+ # Rule 1 is reachable: @dedupe_only defaults to 0 and stays there.
+ matches = find_rows(rows, index_name="ix_never_read",
+ script_type="DISABLE SCRIPT",
+ consolidation_rule__like="Unused Index")
+ assert_test("A-Rule1", "ix_never_read disabled as Unused Index",
+ len(matches) == 1,
+ "found %d (uptime %d days, Rule 1 reachable)"
+ % (len(matches), uptime_days))
+ else:
+ # Rule 1 is unreachable by design. Assert the guard instead.
+ #
+ # Positive controls first: without them "no Unused Index rows" would be
+ # true for any number of uninteresting reasons.
+ present = find_rows(rows, index_name="ix_never_read")
+ assert_test("A-Guard", "positive control: ix_never_read was analyzed",
+ len(present) >= 1,
+ "found %d rows for the index (%s)"
+ % (len(present), [p["script_type"] for p in present]))
+
+ reads = present[0].get("index_reads") if present else None
+ assert_test("A-Guard", "positive control: ix_never_read has zero reads",
+ reads == "0", "index_reads=%s" % reads)
+
+ # The procedure says so itself when asked with @debug = 1.
+ _, debug_stdout = run_proc(server, password, "ic_unused_test", debug=True)
+ assert_test("A-Guard", "@dedupe_only auto-enabled by the uptime guard",
+ GUARD_MESSAGE in debug_stdout,
+ "procedure reported the auto-enable"
+ if GUARD_MESSAGE in debug_stdout
+ else "guard message never appeared with @debug = 1")
+
+ # Therefore Rule 1 never ran, and nothing is recommended as unused.
+ assert_test("A-Guard", "no Unused Index rows while the guard is active",
+ len(unused_rows) == 0,
+ "found %d unused rows (expected 0)" % len(unused_rows))
+
+ # ---- Group B: @min_reads / @min_writes index-level screen ----
+
+ # B1: no floor. ix_hot and ix_warm are key duplicates and should pair up.
+ rows, _ = run_proc(server, password, "ic_min_reads_test", extra=", @min_reads = 0")
+
+ hot = dedupe_rows(rows, "ix_hot")
+ warm = dedupe_rows(rows, "ix_warm")
+ assert_test("B-MinReads", "@min_reads = 0: ix_hot/ix_warm get a dedupe recommendation",
+ len(hot) >= 1 and len(warm) >= 1,
+ "ix_hot=%s ix_warm=%s"
+ % ([h["script_type"] for h in hot], [w["script_type"] for w in warm]))
+
+ # B2: floor above ix_warm's 5 reads. ix_warm is below it, so the pair cannot
+ # be deduped -- both sides must clear the floor on their own.
+ rows, _ = run_proc(server, password, "ic_min_reads_test", extra=", @min_reads = 100")
+
+ warm = dedupe_rows(rows, "ix_warm")
+ assert_test("B-MinReads", "@min_reads = 100: ix_warm not deduped (5 reads, below floor)",
+ len(warm) == 0,
+ "found %d (%s)" % (len(warm), [w["script_type"] for w in warm]))
+
+ # And the half that actually matters: the screen gates dedupe ONLY. Losing a
+ # compression recommendation to a reads floor would be a bug -- compression
+ # eligibility has nothing to do with how often an index is read.
+ matches = find_rows(rows, index_name="ix_warm", script_type="COMPRESSION SCRIPT")
+ assert_test("B-MinReads", "@min_reads = 100: ix_warm still gets COMPRESSION SCRIPT",
+ len(matches) == 1, "found %d" % len(matches))
+
+ matches = find_rows(rows, index_name="pk_ic_min_reads_test",
+ script_type="COMPRESSION SCRIPT")
+ assert_test("B-MinReads", "@min_reads = 100: PK still gets COMPRESSION SCRIPT",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # B3: write floor only. ix_w1/ix_w2 have 60 writes and zero reads, so they
+ # clear on the write side alone.
+ rows, _ = run_proc(server, password, "ic_min_writes_test", extra=", @min_writes = 50")
+
+ w1 = dedupe_rows(rows, "ix_w1")
+ w2 = dedupe_rows(rows, "ix_w2")
+ assert_test("B-MinWrites", "@min_writes = 50, no reads floor: write-only pair still dedupes",
+ len(w1) >= 1 and len(w2) >= 1,
+ "ix_w1=%s ix_w2=%s"
+ % ([w["script_type"] for w in w1], [w["script_type"] for w in w2]))
+
+ # B4: the same pair against a reads floor it cannot meet. Together with B3
+ # this is what makes the floors an OR rather than an AND: identical indexes,
+ # identical usage, deduped under the write floor and not under the read floor.
+ rows, _ = run_proc(server, password, "ic_min_writes_test", extra=", @min_reads = 50")
+
+ w1 = dedupe_rows(rows, "ix_w1")
+ w2 = dedupe_rows(rows, "ix_w2")
+ assert_test("B-MinWrites", "@min_reads = 50 alone: same pair not deduped (0 reads)",
+ len(w1) == 0 and len(w2) == 0,
+ "ix_w1=%s ix_w2=%s"
+ % ([w["script_type"] for w in w1], [w["script_type"] for w in w2]))
+
+ # Compression survives this screen too.
+ matches = find_rows(rows, index_name="ix_w1", script_type="COMPRESSION SCRIPT")
+ assert_test("B-MinWrites", "@min_reads = 50 alone: ix_w1 still gets COMPRESSION SCRIPT",
+ len(matches) == 1, "found %d" % len(matches))
+
+ return results
+
+
+def main():
+ server = "SQL2022"
+ password = "L!nt0044"
+
+ # Parse args
+ args = sys.argv[1:]
+ for i, arg in enumerate(args):
+ if arg == "--server" and i + 1 < len(args):
+ server = args[i + 1]
+ elif arg == "--password" and i + 1 < len(args):
+ password = args[i + 1]
+
+ print("Running rule coverage tests against %s..." % server)
+ print()
+
+ uptime_days = get_uptime_days(server, password)
+ print("Server uptime: %d days" % uptime_days)
+
+ if uptime_days > 7:
+ print("Uptime is over 7 days, so @dedupe_only stays off and Rule 1 is")
+ print("reachable. Asserting that the never-read index is flagged unused.")
+ else:
+ print("Uptime is 7 days or less, so sp_IndexCleanup auto-enables")
+ print("@dedupe_only and Rule 1 cannot run. This is deliberate: usage data")
+ print("from a freshly started instance is not worth acting on. Rule 1")
+ print("itself is therefore not testable on this instance, so what gets")
+ print("asserted instead is THE GUARD -- that @dedupe_only really was")
+ print("auto-enabled and that no index is recommended as unused. This is a")
+ print("verification, not a skip.")
+ print()
+
+ print("Building synthetic fixture in %s..." % TEST_DATABASE)
+ stdout, stderr = run_sql_script(server, password, SETUP_SQL)
+
+ if "Msg " in stdout or ("Msg " in stderr and "Level 16" in stderr):
+ print("ERROR: SQL errors during fixture setup:")
+ print(stdout[-2000:])
+ print(stderr[:500] if stderr else "(empty stderr)")
+ sys.exit(1)
+
+ print()
+
+ try:
+ results = run_tests(server, password, uptime_days)
+ finally:
+ run_sql_script(server, password, CLEANUP_SQL)
+
+ # Report
+ passed = sum(1 for r in results if r["passed"])
+ failed = sum(1 for r in results if not r["passed"])
+
+ for r in results:
+ status = "PASS" if r["passed"] else "FAIL"
+ print(" [%s] %s: %s (%s)" % (status, r["group"], r["name"], r["detail"]))
+
+ print()
+ print("Results: %d passed, %d failed, %d total" % (passed, failed, len(results)))
+
+ if failed > 0:
+ print()
+ print("FAILED TESTS:")
+ for r in results:
+ if not r["passed"]:
+ print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"]))
+ sys.exit(1)
+ else:
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
From ef3098b976e2608a09b9f8990037b9b6c13182d8 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 16 Jul 2026 21:43:29 -0400
Subject: [PATCH 18/60] sp_IndexCleanup: keep key-subset includes through Rule
7.6; unbreak the harness
Fixes a regression introduced by 11b3373 and the reason nothing caught it.
Rule 7.6 recomputed the winner's includes from #index_details but gathered only
Key Duplicate losers. Rule 7.5 can rewrite a row Rule 6 already processed as a
Key Superset into MAKE UNIQUE, so a winner arriving at 7.6 may own Key SUBSET
losers too. Their includes - already merged in by Rule 6 - were discarded, while
their DISABLE still ran:
uq_ow_cd UNIQUE (col_c, col_d)
ix_ow_cd (col_c, col_d) INCLUDE (col_e)
ix_ow_cd2 (col_c, col_d) INCLUDE (col_a)
ix_ow_c (col_c) INCLUDE (col_b)
before 11b3373: INCLUDE ([col_a], [col_b], [col_e])
after: INCLUDE ([col_a], [col_e]) <- col_b gone, ix_ow_c still disabled
Every script executes, so this is the succeed-but-wrong class the execute check
is structurally blind to. The old string-splitting code survived it by accident:
it read the accumulated included_columns, so an earlier merge carried forward.
Re-deriving from disk does not, so every loser now has to be gathered explicitly.
7.6 matches losers on target_index_name rather than key_filter_hash, since key
subsets do not share the winner's keys, and excludes columns already in the
winner's key (Msg 1909).
The harness could not see SQL errors at all. run_tests.py and
fixture_cases_test.py checked stderr for "Msg ", but go-sqlcmd reports errors on
stdout, so the detection was decorative. Both now scan both streams for any
severity 16+ and fail loudly with the messages.
That check immediately surfaced what it had been hiding: adversarial_test.sql
failed partway through on EVERY run. test_ic_interact drew col_c and col_d
randomly from 200 and 100 buckets across 10,000 rows, so
uq_int_cd UNIQUE (col_c, col_d) could never be created (Msg 1505 -> Msg 1750),
and the read loop then aborted hinting the missing index (Msg 308). Group 12b
was never tested, and the "KNOWN ISSUE" comment blaming the procedure was wrong:
on clean data it gets the group right. col_c is now ROW_NUMBER(), so the
constraint is satisfiable; col_a and col_b stay duplicated, which 12a needs.
Group 12b is re-enabled and extended into a regression test for the above. It
gains ix_int_cd2, a second key duplicate with a different include -- without it
the winner never routes through 7.6 and the interaction cannot fire, which is
why a fixture lacking it looked healthy. Its final assertion checks that the
merge keeps both its own include and the disabled subset's. Verified in both
directions: it FAILS against the build currently on dev (INCLUDE
([col_a], [col_e]), col_b absent) and PASSES against this one (INCLUDE
([col_a], [col_b], [col_e])).
Found by an independent review after the regression had already shipped. Worth
recording why: 70 assertions, a full baseline diff, and an adversarial pass all
went green with this bug present, because no fixture combined a subset-with-
include, a unique constraint, and two key duplicates in one group.
Verified on 2016/2017/2019/2022/2025: compiles clean; run_tests.py 32/32
(up from 28, group 12b live); fixture_cases_test.py 31/31; rule_coverage_test.py
11/11; no_access_test.py 4/4.
Co-Authored-By: Claude Opus 4.8
---
sp_IndexCleanup/sp_IndexCleanup.sql | 41 ++++++++---
sp_IndexCleanup/tests/adversarial_test.sql | 36 +++++++++-
sp_IndexCleanup/tests/fixture_cases_test.py | 17 ++++-
sp_IndexCleanup/tests/run_tests.py | 80 +++++++++++++++++++--
4 files changed, 154 insertions(+), 20 deletions(-)
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index 8d4e4478..89130592 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -4490,8 +4490,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ia_winner
SET
/*
- Merge the disabled Key Duplicates' includes into the index being made
- unique.
+ Merge the includes of every index being disabled in this winner's favor
+ into the index being made unique.
Sourced from #index_details, one row per column, rather than splitting
included_columns on ', '. A column name may legally contain a comma and
@@ -4499,9 +4499,21 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
fragments the DISTINCT sort could separate, producing a broken INCLUDE
list on a script whose paired DISABLE still ran.
- Winner and losers are key duplicates, so their keys match and SQL Server
- would not allow a key column to also be an include. No key exclusion is
- needed.
+ Both loser rules matter, and missing one is not a cosmetic error. Rule 7.5
+ can rewrite a row Rule 6 already processed as a Key Superset into MAKE
+ UNIQUE, so a winner arriving here may own Key SUBSET losers as well as Key
+ DUPLICATE ones. Reading only Key Duplicates discarded the subsets'
+ includes that Rule 6 had already merged in, while their DISABLE still
+ ran - a covering column silently gone, on a script that executes cleanly.
+ The old string-splitting code survived this by accident: it read the
+ accumulated included_columns, so an earlier merge carried forward.
+ Re-deriving from disk does not, which is why every loser has to be
+ gathered here explicitly.
+
+ Key duplicates share the winner's keys, but key subsets do not, so losers
+ are matched on target_index_name rather than key_filter_hash, and a column
+ already in the winner's key is excluded - SQL Server rejects an index that
+ includes its own key column (Msg 1909).
*/
ia_winner.included_columns =
STUFF
@@ -4516,20 +4528,33 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(
/* The winner's own includes */
idc.index_hash = ia_winner.index_hash
- /* Plus those of every duplicate being disabled for it */
+ /* Plus those of every index being disabled in its favor */
OR EXISTS
(
SELECT
1/0
FROM #index_analysis AS ia_loser
WHERE ia_loser.scope_hash = ia_winner.scope_hash
- AND ia_loser.key_filter_hash = ia_winner.key_filter_hash
AND ia_loser.action = N'DISABLE'
- AND ia_loser.consolidation_rule = N'Key Duplicate'
AND ia_loser.target_index_name = ia_winner.index_name
+ AND ia_loser.consolidation_rule IN
+ (
+ N'Key Duplicate',
+ N'Key Subset'
+ )
AND ia_loser.index_hash = idc.index_hash
)
)
+ /* A column already in the winner's key doesn't need including */
+ AND NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_details AS idk
+ WHERE idk.index_hash = ia_winner.index_hash
+ AND idk.is_included_column = 0
+ AND idk.column_name = idc.column_name
+ )
GROUP BY
idc.column_name
ORDER BY
diff --git a/sp_IndexCleanup/tests/adversarial_test.sql b/sp_IndexCleanup/tests/adversarial_test.sql
index cf94ecca..68a0458e 100644
--- a/sp_IndexCleanup/tests/adversarial_test.sql
+++ b/sp_IndexCleanup/tests/adversarial_test.sql
@@ -216,9 +216,21 @@ SELECT TOP (10000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
ABS(CHECKSUM(NEWID())) % 500, ABS(CHECKSUM(NEWID())) % 200
FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b;
+/*
+col_c is ROW_NUMBER(), not a random value, and that matters. Group 12b creates
+uq_int_cd UNIQUE (col_c, col_d) below. With col_c and col_d drawn randomly from
+200 and 100 values across 10,000 rows, duplicate pairs were a certainty, so the
+constraint failed to create on EVERY run with Msg 1505, Msg 1750 followed, and
+the read loop's forced hint on the missing index aborted the usage batch with
+Msg 308. Group 12b was never actually tested, and the suite could not see any of
+it because the runner checked stderr while go-sqlcmd reports errors on stdout.
+
+A unique col_c makes (col_c, col_d) unique regardless of col_d. col_a and col_b
+stay duplicated on purpose - the subset chain in 12a needs repeated values.
+*/
INSERT INTO dbo.test_ic_interact (col_a, col_b, col_c, col_d, col_e)
SELECT TOP (10000) ABS(CHECKSUM(NEWID())) % 1000, ABS(CHECKSUM(NEWID())) % 500,
- ABS(CHECKSUM(NEWID())) % 200, ABS(CHECKSUM(NEWID())) % 100, LEFT(NEWID(), 20)
+ ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), ABS(CHECKSUM(NEWID())) % 100, LEFT(NEWID(), 20)
FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b;
GO
@@ -301,9 +313,29 @@ CREATE INDEX ix_int_ab ON dbo.test_ic_interact (col_a, col_b);
CREATE INDEX ix_int_abc ON dbo.test_ic_interact (col_a, col_b, col_c);
/* 12b: UC exact match AND UC superset on same table */
+/*
+This group is the one shape where three rules meet, and every piece of it is
+load-bearing:
+
+ uq_int_cd the unique CONSTRAINT that Rule 7.5 wants to replace
+ ix_int_cd a key duplicate of it, with its own include
+ ix_int_cd2 a SECOND key duplicate with a DIFFERENT include, which is what
+ routes the winner through Rule 7.6 at all
+ ix_int_c a key SUBSET carrying an include of its own
+
+Rule 6 merges ix_int_c's col_b into the winner as a Key Subset. Rule 7.5 then
+rewrites that same row to MAKE UNIQUE. Rule 7.6 recomputes the winner's includes,
+and if it gathers only Key Duplicate losers it silently drops col_b while
+ix_int_c's DISABLE still runs - a covering column gone, on scripts that all
+execute cleanly, which the execute check cannot see.
+
+Drop ix_int_cd2 and the interaction stops firing: with one duplicate there is no
+7.6 pass to lose anything. That is why a fixture without it looked healthy.
+*/
ALTER TABLE dbo.test_ic_interact ADD CONSTRAINT uq_int_cd UNIQUE (col_c, col_d);
CREATE INDEX ix_int_cd ON dbo.test_ic_interact (col_c, col_d) INCLUDE (col_e);
-CREATE INDEX ix_int_c ON dbo.test_ic_interact (col_c);
+CREATE INDEX ix_int_cd2 ON dbo.test_ic_interact (col_c, col_d) INCLUDE (col_a);
+CREATE INDEX ix_int_c ON dbo.test_ic_interact (col_c) INCLUDE (col_b);
/* Group 13: @min_reads filter — run separately in Python */
GO
diff --git a/sp_IndexCleanup/tests/fixture_cases_test.py b/sp_IndexCleanup/tests/fixture_cases_test.py
index d2f2d886..d684bf55 100644
--- a/sp_IndexCleanup/tests/fixture_cases_test.py
+++ b/sp_IndexCleanup/tests/fixture_cases_test.py
@@ -487,9 +487,20 @@ def main():
stdout, stderr = run_sqlcmd(server, password, input_file=FIXTURE_FILE,
timeout=600)
- if "Msg " in stderr and "Level 16" in stderr:
- print("ERROR: SQL errors detected:")
- print(stderr)
+ # go-sqlcmd reports SQL errors on STDOUT, so checking stderr alone made
+ # this decorative: a fixture could fail partway through and the suite
+ # would still go green, asserting against a half-built schema. Match any
+ # severity 16+ on both streams.
+ errors = (re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stdout or "") +
+ re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stderr or ""))
+
+ if errors:
+ print("ERROR: SQL errors detected while building the fixture:")
+ for e in errors:
+ print(" " + e)
+ print()
+ print("The fixture did not build correctly, so the assertions below")
+ print("would be testing something other than what they claim.")
sys.exit(1)
rows = parse_output(stdout)
diff --git a/sp_IndexCleanup/tests/run_tests.py b/sp_IndexCleanup/tests/run_tests.py
index a4db5a11..65f1566e 100644
--- a/sp_IndexCleanup/tests/run_tests.py
+++ b/sp_IndexCleanup/tests/run_tests.py
@@ -12,6 +12,19 @@
import re
+def find_sql_errors(text):
+ """Return any SQL errors of severity 16 or higher found in text.
+
+ go-sqlcmd reports errors on stdout, so both streams have to be checked.
+ Matching the severity numerically catches Level 16 through 19 rather than
+ only the literal "Level 16".
+ """
+ if not text:
+ return []
+
+ return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text)
+
+
def run_sqlcmd(server, password):
"""Run the test SQL and capture output."""
cmd = [
@@ -309,10 +322,52 @@ def assert_test(group, name, condition, detail=""):
len(matches) == 0, f"found {len(matches)} (expected 0)")
# 12b: UC + NC + subset on same table
- # KNOWN ISSUE: uq_int_cd, ix_int_cd, and ix_int_c don't appear in
- # output at all — needs investigation with @debug = 1 to determine
- # if they're excluded at collection or rule processing stage.
- # Skipping assertion for now — tracked as issue for investigation.
+ # 12b: UC + key-duplicate NC + key-subset NC on the same table.
+ #
+ # This was skipped for a long time with a comment saying these three indexes
+ # "don't appear in output at all -- needs investigation". They were absent
+ # because the FIXTURE was broken, not the procedure: col_c and col_d were
+ # random values from 200 and 100 buckets across 10,000 rows, so
+ # uq_int_cd UNIQUE (col_c, col_d) failed to create on every run (Msg 1505),
+ # and the read loop then aborted hinting the missing index (Msg 308). The
+ # runner could not see any of it because it checked stderr and go-sqlcmd
+ # reports errors on stdout. Both are fixed; on clean data the procedure gets
+ # this group right.
+
+ # 12b: the constraint is replaced by its nonclustered twin
+ matches = find_rows(rows, table_name="test_ic_interact", index_name="uq_int_cd",
+ script_type="DISABLE CONSTRAINT SCRIPT")
+ assert_test("12-Interact", "12b: uq_int_cd constraint dropped for ix_int_cd",
+ len(matches) == 1, f"found {len(matches)}")
+
+ # 12b: that twin is made unique
+ matches = find_rows(rows, table_name="test_ic_interact", index_name="ix_int_cd",
+ script_type="MERGE SCRIPT")
+ has_unique = any("CREATE UNIQUE" in m.get("script", "") for m in matches)
+ assert_test("12-Interact", "12b: ix_int_cd made unique to replace the constraint",
+ has_unique, f"found {len(matches)} merge rows, unique={has_unique}")
+
+ # 12b: the key subset is disabled into it
+ matches = find_rows(rows, table_name="test_ic_interact", index_name="ix_int_c",
+ script_type="DISABLE SCRIPT")
+ assert_test("12-Interact", "12b: ix_int_c (key subset) disabled",
+ len(matches) == 1, f"found {len(matches)}")
+
+ # 12b: THE load-bearing one. ix_int_c is being disabled, so whatever it
+ # covered has to survive in the index replacing it. Rule 6 merges col_b in as
+ # a Key Subset; Rule 7.5 then rewrites the row to MAKE UNIQUE and Rule 7.6
+ # recomputes the includes. A 7.6 that gathers only Key Duplicate losers drops
+ # col_b here while ix_int_c's DISABLE still runs -- coverage silently gone on
+ # scripts that all execute cleanly, which the execute check cannot see.
+ matches = find_rows(rows, table_name="test_ic_interact", index_name="ix_int_cd",
+ script_type="MERGE SCRIPT")
+ script = matches[0].get("script", "") if matches else ""
+ include = script[script.find("INCLUDE"):script.find(")", script.find("INCLUDE")) + 1] if "INCLUDE" in script else ""
+ kept_subset_col = "col_b" in include
+ kept_own_col = "col_e" in include
+ assert_test("12-Interact", "12b: merge keeps BOTH its own include and the disabled subset's",
+ kept_subset_col and kept_own_col,
+ f"INCLUDE={include or '(none)'} col_b={kept_subset_col} col_e={kept_own_col}")
return results
@@ -334,9 +389,20 @@ def main():
stdout, stderr = run_sqlcmd(server, password)
- if "Msg " in stderr and "Level 16" in stderr:
- print("ERROR: SQL errors detected:")
- print(stderr)
+ # go-sqlcmd writes SQL errors to STDOUT, not stderr. Checking stderr alone
+ # made this detection decorative: adversarial_test.sql was failing partway
+ # through on every run (Msg 1505 on test_ic_interact) and the suite stayed
+ # green, so group 12 was never really tested. Check both streams, and match
+ # any severity 16+ rather than the literal string "Level 16".
+ errors = find_sql_errors(stdout) + find_sql_errors(stderr)
+
+ if errors:
+ print("ERROR: SQL errors detected while running the fixture:")
+ for e in errors:
+ print(" " + e)
+ print()
+ print("The fixture did not build correctly, so the assertions below")
+ print("would be testing something other than what they claim.")
sys.exit(1)
rows = parse_output(stdout)
From 9f1e07cd339dfa980fbfde58fae3e9b2b4081ae0 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 16 Jul 2026 23:14:27 -0400
Subject: [PATCH 19/60] sp_IndexCleanup: never dedupe a primary key; kill two
surviving mutants
Nonclustered primary keys were unprotected. is_eligible_for_dedupe read
CASE WHEN i.type = 2 THEN 1
WHEN (i.type = 1 OR i.is_primary_key = 1) THEN 0 END
and a nonclustered PK is type 2, so it matched the first branch and entered
dedupe as an ordinary unique index. The is_primary_key branch was unreachable
for exactly the indexes it was written for. Proven live:
- Rule 5 emitted CREATE UNIQUE INDEX [pk...] ... WITH (DROP_EXISTING = ON)
against the PK, which fails with Msg 1907, while the paired DISABLE of the
duplicate succeeded. Silent include loss.
- A PK could receive ALTER INDEX [pk...] DISABLE. That one does not error: it
succeeds, silently disables every inbound FOREIGN KEY, and orphan rows then
insert with no complaint. Referential integrity off, no error anywhere.
Reordering the CASE fixes both at the source. A unique CONSTRAINT stays eligible
on purpose - Rules 7/7.5 exist to replace one with an equivalent nonclustered
index and need the row - but nothing should ever drop a primary key, so it is
excluded instead. Excluding it costs nothing: the compression backfill already
selected WHERE fo.index_id = 1 OR id.is_primary_key = 1 and already rendered
ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY NONCLUSTERED. That code was
unreachable until now, which is also why the old build silently LOST a PK's
compression recommendation whenever a dedupe rule consumed its row.
Rule 7.6 gets the is_eligible_for_dedupe EXISTS its sibling rules already carry,
and this one is not bookkeeping. Under @get_all_databases, #index_details is
truncated per database while #index_analysis accumulates for the final output, so
every rule re-runs over already-processed databases. Rules 2/3/5/7 fall out
harmlessly because their EXISTS finds nothing for stale rows. Rule 7.6 had only a
NOT EXISTS, which passes VACUOUSLY against an empty table - so it paired a prior
database's leftover MAKE UNIQUE winner with that database's backfilled PK and
marked it DISABLE, and the script-generation backstops (which also read
#index_details) passed vacuously in turn. An ALTER INDEX [pk...] DISABLE shipped
from an otherwise fixed build. An EXISTS fails closed on a stale row where a
NOT EXISTS fails open.
is_primary_key also joins is_unique_constraint at both script-generation
backstops, as defence in depth rather than the actual guard.
Known trade-off, accepted: a plain index that exactly duplicates a nonclustered
PK is no longer recommended for removal. That is a LOST recommendation, never a
wrong one, and it is the same trade already documented for unique constraints. A
PK cannot absorb includes via DROP_EXISTING, so recovering it needs a design
decision rather than a patch.
Tests: rule_coverage_test.py 11 -> 32.
- Group C: PK with a key duplicate, and with an exact duplicate. Asserts no
merge targets it, no DISABLE names it, nothing claims it as a winner - with
positive controls proving the PK still gets its COMPRESSION SCRIPT, so the
fix cannot pass by making PKs invisible. 18/7 red against the pre-fix build.
- Groups D and E kill two mutants that previously survived all 74 assertions:
removing the ' DESC' embedding from key_columns (a false Key Duplicate whose
DISABLE executes cleanly - Rule 5 has no direction check, that string is its
only defence), and removing Rule 6's key-column exclusion (Msg 1909).
- Group F builds two databases and runs @get_all_databases = 1, covering the
truncation asymmetry above. Its positive controls assert CrapA was processed
first and Rule 7.5 actually fired, because the loop orders by database_id -
creation order, not name - and if that ever flipped the group would go green
while testing nothing. 30/2 red without the Rule 7.6 guard.
Every new assertion was watched failing against the defect it covers before being
kept. Found by independent review; the PK defects predate this work, the Rule 7.6
vacuous-guard interaction was surfaced reviewing the fix for them.
Verified on 2016/2017/2019/2022/2025: compiles clean; rule_coverage_test.py
32/32; run_tests.py 32/32; fixture_cases_test.py 31/31; no_access_test.py 4/4.
Fixtures self-clean, including the two databases, on pass and on failure.
Co-Authored-By: Claude Opus 4.8
---
sp_IndexCleanup/sp_IndexCleanup.sql | 98 ++-
sp_IndexCleanup/tests/rule_coverage_test.py | 768 +++++++++++++++++++-
2 files changed, 854 insertions(+), 12 deletions(-)
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index 89130592..aeb5d174 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -2487,6 +2487,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
still analyzed by the pre-filter but then dropped here unless some
single partition cleared the floor alone - the two filters disagreed
about what "@min_rows" means.
+
+ On is_eligible_for_dedupe below: the CASE tests i.type = 1 OR
+ i.is_primary_key = 1 FIRST, and that order is load bearing. A
+ nonclustered primary key is type = 2, so testing type = 2 first matched
+ it as an ordinary nonclustered index and the is_primary_key branch could
+ never be reached for the one kind of index it was written for. That let
+ primary keys into the dedupe rules, which check only
+ is_unique_constraint - 0 for a primary key. Rule 5 then emitted
+ CREATE UNIQUE INDEX ... WITH (DROP_EXISTING = ON) against the PK
+ (Msg 1907, while the paired DISABLE of the other index still ran), and
+ Rule 2 could emit ALTER INDEX ... DISABLE against it, which does not
+ error: it succeeds, and silently disables every inbound foreign key
+ along with it.
+
+ Primary keys are ineligible for dedupe. UNIQUE constraints deliberately
+ are NOT: they stay eligible because Rule 7 and Rule 7.5 exist to replace
+ a unique constraint with an equivalent nonclustered index, and they need
+ the row to do it. A primary key has no such replacement path - nothing
+ should ever drop one - so it is excluded here instead.
+
+ Excluding a PK does not cost it its compression recommendation. The
+ insert that backfills #index_analysis for compression already selects
+ WHERE fo.index_id = 1 OR id.is_primary_key = 1, and already renders the
+ definition as ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY
+ NONCLUSTERED. That code was unreachable until this CASE was fixed.
+
+ Keep prose out of the string literal below. Everything up to the first
+ CONVERT(nvarchar(max), ...) concatenates as nvarchar(4000), so padding
+ it silently truncates the batch mid-statement instead of failing loudly.
*/
SELECT
@sql = N'
@@ -2575,14 +2604,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
us.last_user_update,
is_eligible_for_dedupe =
CASE
- WHEN i.type = 2
- THEN 1
WHEN
(
i.type = 1
OR i.is_primary_key = 1
)
THEN 0
+ WHEN i.type = 2
+ THEN 1
END
FROM ' + QUOTENAME(@current_database_name) + N'.sys.indexes AS i
JOIN ' + QUOTENAME(@current_database_name) + N'.sys.objects AS o
@@ -4483,6 +4512,42 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id_uc.index_hash = ia_dup.index_hash
AND id_uc.is_unique_constraint = 1
)
+ /*
+ Both sides must still be present in #index_details and eligible, exactly as
+ Rules 2, 3, 5 and 7 require. This is not redundant bookkeeping.
+
+ Under @get_all_databases, #index_details is truncated for each database
+ (see the reset list above) while #index_analysis accumulates across all of
+ them for the final output. Every rule then re-runs over rows belonging to
+ databases already processed. The sibling rules fall out harmlessly because
+ their EXISTS against #index_details finds nothing for those stale rows.
+ This rule had only a NOT EXISTS, which passes VACUOUSLY once the table is
+ empty - so it paired a previous database's leftover MAKE UNIQUE winner with
+ that database's backfilled primary key and marked the PK DISABLE. The
+ script-generation backstops read #index_details too, so they passed
+ vacuously in turn and an ALTER INDEX [pk...] DISABLE shipped: it executes
+ cleanly, silently disables every inbound foreign key, and orphan rows insert
+ afterward with no error.
+
+ An EXISTS is the correct shape here precisely because it fails closed on a
+ stale row, where a NOT EXISTS fails open.
+ */
+ AND EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_details AS id_dup
+ WHERE id_dup.index_hash = ia_dup.index_hash
+ AND id_dup.is_eligible_for_dedupe = 1
+ )
+ AND EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #index_details AS id_win
+ WHERE id_win.index_hash = ia_winner.index_hash
+ AND id_win.is_eligible_for_dedupe = 1
+ )
OPTION(RECOMPILE);
/* Merge includes from the disabled Key Duplicates into the MAKE UNIQUE index */
@@ -5500,6 +5565,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(DROP_EXISTING = ON) can never succeed against a constraint-backed
index (Msg 1907), so no matter what upstream rules decide, never
emit a merge script for one.
+
+ "Constraint-backed" means a primary key as well as a unique
+ constraint. is_unique_constraint is 0 for a primary key, so testing
+ it alone left PKs unguarded here.
*/
AND NOT EXISTS
(
@@ -5507,7 +5576,11 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
1/0
FROM #index_details AS id_uc_guard
WHERE id_uc_guard.index_hash = ia.index_hash
- AND id_uc_guard.is_unique_constraint = 1
+ AND
+ (
+ id_uc_guard.is_unique_constraint = 1
+ OR id_uc_guard.is_primary_key = 1
+ )
)
/*
Second backstop, behind the Rule 7 / 7.5 guards: any script that
@@ -5706,14 +5779,29 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id.is_included_column = 0 /* Get only one row per index */
AND id.key_ordinal > 0
WHERE ia.action = N'DISABLE'
- /* Exclude unique constraints - they are handled by DISABLE CONSTRAINT scripts */
+ /*
+ Exclude unique constraints - they are handled by DISABLE CONSTRAINT scripts.
+
+ Primary keys are excluded here too, but they are NOT rerouted to that
+ path: a unique constraint can legitimately be replaced by a promoted
+ nonclustered index and dropped, a primary key never can. Excluding one
+ here means it gets no script at all, which is the only safe answer.
+ ALTER INDEX ... DISABLE against a primary key does not error - it
+ succeeds, and silently disables every foreign key referencing it, after
+ which orphan rows insert cleanly. A script that succeeds while being
+ wrong is the worst thing this procedure can emit.
+ */
AND NOT EXISTS
(
SELECT
1/0
FROM #index_details AS id_uc
WHERE id_uc.index_hash = ia.index_hash
- AND id_uc.is_unique_constraint = 1
+ AND
+ (
+ id_uc.is_unique_constraint = 1
+ OR id_uc.is_primary_key = 1
+ )
)
/* Also exclude any index that is also going to be made unique in rule 7.5 */
AND NOT EXISTS
diff --git a/sp_IndexCleanup/tests/rule_coverage_test.py b/sp_IndexCleanup/tests/rule_coverage_test.py
index d9eb43d7..89668e92 100644
--- a/sp_IndexCleanup/tests/rule_coverage_test.py
+++ b/sp_IndexCleanup/tests/rule_coverage_test.py
@@ -1,14 +1,31 @@
"""
sp_IndexCleanup Rule Coverage Tests
===================================
-Covers two things nothing else in this directory tests: Rule 1 (unused index
-detection) and the @min_reads / @min_writes index-level screen.
+Covers things nothing else in this directory tests: Rule 1 (unused index
+detection), the @min_reads / @min_writes index-level screen, and four ways the
+procedure can emit a script that is wrong.
+
+The ranking that decides what is worth asserting here: this procedure emits DDL
+that people run against production. A script that ERRORS is nearly harmless --
+it stops, and nobody's data moves. A script that SUCCEEDS while being wrong is
+the real hazard, because there is nothing to notice. Groups C, D, E and F all
+target that class, so they assert on the script text the procedure produces
+rather than on whether some execution raised an error.
+
+Every absence assertion below is paired with a positive control. "No DISABLE
+script named the PK" is trivially true if the PK was never analyzed, so each
+group first proves the index was in play.
Builds its own synthetic fixture in the Crap database -- three ~20,000 row tables,
scoped with @table_name so each procedure run is fast. It deliberately does not
touch StackOverflow2013: fixture_cases_test.py already spends two minutes there,
and this one is meant to be quick. Drops its tables afterward.
+Group F additionally builds two throwaway databases, because the defect it
+covers only exists across the database loop and cannot be reproduced inside a
+single database. They are dropped unconditionally afterward, including when an
+assertion fails or the fixture itself does not build.
+
(a) Rule 1, unused index detection.
The procedure auto-enables @dedupe_only when server uptime is <= 7 days, and
@@ -43,11 +60,76 @@
the object-level filter on its own, which means these cases exercise the
index-level screen specifically rather than the coarser per-table filter.
+(c) Nonclustered PRIMARY KEYs are constraint-backed.
+
+ is_primary_key is not is_unique_constraint: the latter is 0 for a primary
+ key, so a rule that guards only on is_unique_constraint leaves PKs exposed.
+ A nonclustered PK is type = 2, which is also why it used to look like an
+ ordinary unique index to the dedupe rules.
+
+ Two proven consequences, both fixed:
+ - a key duplicate earned the PK a MERGE (CREATE UNIQUE INDEX ...
+ DROP_EXISTING against a constraint -> Msg 1907) while the paired DISABLE
+ of the other index succeeded, silently dropping a covering column.
+ - an exact duplicate with tied priorities put the PK on the losing side of
+ an alphabetical tiebreak and emitted ALTER INDEX [pk...] DISABLE. That
+ one does not error. It succeeds, disables every inbound foreign key, and
+ orphan rows insert cleanly afterward.
+
+ The compression positive controls are the other half: the fix must not work
+ by making primary keys invisible.
+
+(d) Sort direction is part of an index's identity.
+
+ Rule 2 and Rule 3 each carry an explicit is_descending_key comparison.
+ Rule 5 carries none -- the ' DESC' embedded into key_columns is the only
+ thing keeping (col_a, col_b) and (col_a, col_b DESC) from hashing alike.
+ Without it they become a false Key Duplicate whose DISABLE runs cleanly.
+
+(e) A merged index may not INCLUDE its own key column.
+
+ Rule 6 merges a subset's includes into the superset absorbing it. If the
+ subset's include is already a key of the superset, the emitted CREATE INDEX
+ lists the column twice (Msg 1909) while the subset's DISABLE still runs.
+
+(f) #index_details is per-database. #index_analysis is not.
+
+ Under @get_all_databases the procedure TRUNCATES #index_details for each
+ database in the loop, while #index_analysis ACCUMULATES across all of them
+ to build the final output. Every rule therefore re-runs over rows belonging
+ to databases that were already processed.
+
+ Rules 2/3/5/7 survive that because of their shape: each requires
+ EXISTS (#index_details ... is_eligible_for_dedupe = 1), which finds nothing
+ for a stale row and so fails CLOSED. Rule 7.6 carried only a
+ NOT EXISTS (... is_unique_constraint = 1), and a NOT EXISTS against an
+ emptied table passes VACUOUSLY. On the second database's pass it therefore
+ paired the FIRST database's leftover MAKE UNIQUE winner with that database's
+ primary key and marked the PK DISABLE. The script-generation backstops read
+ #index_details too, so they passed vacuously in turn and this shipped:
+
+ ALTER INDEX [pk_mdb] ON [CrapA].[dbo].[mdb_test] DISABLE;
+
+ It executes cleanly, silently disables every inbound foreign key, and orphan
+ rows then insert with no error. A NOT EXISTS fails open on a stale row where
+ an EXISTS fails closed, which is the whole reason the guard has that shape.
+
+ This is the only group that needs more than one database, so it builds two.
+ The defect IS the loop, and a single-database fixture cannot express it.
+
+ The positive controls carry more weight here than anywhere else in this file,
+ because every assertion is an assertion of absence and the bug has four
+ preconditions -- CrapA processed first, CrapA's table analyzed, Rule 7.5
+ actually producing the MAKE UNIQUE winner, and CrapB reached so a second
+ iteration happens. If any one of them silently stopped being true, every
+ assertion below would go green while testing nothing at all.
+
Usage:
python rule_coverage_test.py [--server SQL2022] [--password "L!nt0044"]
"""
import os
+import re
import subprocess
import sys
import tempfile
@@ -55,6 +137,15 @@
TEST_DATABASE = "Crap"
+# Group F's two throwaway databases. CrapA is processed FIRST and is the one
+# whose leftover rows go stale during CrapB's pass, so CrapA owns the primary
+# key the bug used to disable. See MDB_SETUP_SQL for why the order is by
+# creation and not by name.
+MDB_DATABASE_FIRST = "CrapA"
+MDB_DATABASE_SECOND = "CrapB"
+MDB_TABLE = "mdb_test"
+MDB_PK = "pk_mdb"
+
# Rows that represent a dedupe action. A COMPRESSION SCRIPT row is not one.
DEDUPE_SCRIPT_TYPES = ("DISABLE SCRIPT", "MERGE SCRIPT", "DISABLE CONSTRAINT SCRIPT")
@@ -66,6 +157,10 @@
DROP TABLE IF EXISTS dbo.ic_min_reads_test;
DROP TABLE IF EXISTS dbo.ic_min_writes_test;
DROP TABLE IF EXISTS dbo.ic_unused_test;
+DROP TABLE IF EXISTS dbo.ic_pk_key_test;
+DROP TABLE IF EXISTS dbo.ic_pk_exact_test;
+DROP TABLE IF EXISTS dbo.ic_sort_dir_test;
+DROP TABLE IF EXISTS dbo.ic_key_include_test;
GO
/*
@@ -113,6 +208,97 @@
filler varchar(100) NOT NULL,
CONSTRAINT pk_ic_unused_test PRIMARY KEY CLUSTERED (id)
);
+
+/*
+ic_pk_key_test: a NONCLUSTERED primary key with a key duplicate next to it.
+Same keys, different includes, so Rule 5 pairs them. The PK is unique and the
+plain index is not, which makes the PK the "winner" and earns it MERGE
+INCLUDES -> CREATE UNIQUE INDEX ... WITH (DROP_EXISTING = ON) against a
+constraint-backed index. That fails with Msg 1907 while the paired DISABLE of
+ix_pk_key_dupe succeeds, silently dropping col_b coverage.
+
+is_primary_key is NOT is_unique_constraint: it is 0 for a primary key, so every
+rule that guards only on is_unique_constraint leaves a PK exposed.
+*/
+CREATE TABLE
+ dbo.ic_pk_key_test
+(
+ id integer NOT NULL,
+ col_b integer NOT NULL,
+ filler varchar(100) NOT NULL,
+ CONSTRAINT pk_ic_pk_key_test PRIMARY KEY NONCLUSTERED (id)
+);
+
+/*
+ic_pk_exact_test: a NONCLUSTERED primary key with an exact duplicate.
+
+The names matter. Rule 2 breaks a priority tie alphabetically and disables the
+LATER name, so the PK is deliberately named to sort after the plain index. Both
+indexes are given exactly one seek below so their priorities genuinely tie
+(500 unique + 200 seeks) and the tiebreak is what decides -- without that the
+PK wins on its own and the bug never shows.
+
+ALTER INDEX ... DISABLE against a PK does not error. It succeeds, disables every
+inbound foreign key with it, and orphan rows then insert cleanly. A script that
+succeeds while being wrong is the worst thing this procedure can emit, which is
+why this asserts on the script text rather than on an execution error.
+*/
+CREATE TABLE
+ dbo.ic_pk_exact_test
+(
+ id integer NOT NULL,
+ filler varchar(100) NOT NULL,
+ CONSTRAINT zz_pk_ic_pk_exact_test PRIMARY KEY NONCLUSTERED (id)
+);
+
+/*
+ic_sort_dir_test: two indexes identical but for the sort direction of the
+second key column, with different includes so they land in Rule 5 rather than
+Rule 2.
+
+Rule 2 and Rule 3 each carry an explicit is_descending_key comparison. Rule 5
+carries NONE: its only defense is that key_columns embeds ' DESC' into the
+string the key_filter_hash is built from. Delete that embedding and these two
+become a false Key Duplicate -- the DESC index gets a cleanly-executing DISABLE
+and the survivor is ASC-only, so every query depending on the DESC ordering
+silently regresses. Nothing errors.
+
+Both indexes are given reads so Rule 1 can never claim either on a
+long-uptime server, which would make the assertions below pass vacuously.
+ix_sort_asc gets seeks AND scans (200 + 100) while ix_sort_desc gets seeks
+only (200), so priorities differ and the loser is deterministic.
+*/
+CREATE TABLE
+ dbo.ic_sort_dir_test
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_b integer NOT NULL,
+ col_c integer NOT NULL,
+ col_d integer NOT NULL,
+ filler varchar(100) NOT NULL,
+ CONSTRAINT pk_ic_sort_dir_test PRIMARY KEY CLUSTERED (id)
+);
+
+/*
+ic_key_include_test: a key subset whose include is already a key column of the
+superset that absorbs it.
+
+Rule 3 disables ix_ki_subset (col_a) in favor of ix_ki_superset (col_a, col_b),
+and Rule 6 merges the subset's includes into the superset. col_b is already in
+the superset's KEY, and SQL Server rejects an index that includes its own key
+column with Msg 1909. Rule 6's key-column exclusion is the only thing that
+keeps col_b out of the INCLUDE list.
+*/
+CREATE TABLE
+ dbo.ic_key_include_test
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_b integer NOT NULL,
+ filler varchar(100) NOT NULL,
+ CONSTRAINT pk_ic_key_include_test PRIMARY KEY CLUSTERED (id)
+);
GO
INSERT INTO
@@ -169,6 +355,72 @@
FROM sys.all_columns AS ac1
CROSS JOIN sys.all_columns AS ac2
OPTION(MAXDOP 1);
+
+INSERT INTO
+ dbo.ic_pk_key_test
+(
+ id,
+ col_b,
+ filler
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100,
+ filler = REPLICATE('x', 100)
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+INSERT INTO
+ dbo.ic_pk_exact_test
+(
+ id,
+ filler
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ filler = REPLICATE('x', 100)
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+INSERT INTO
+ dbo.ic_sort_dir_test
+(
+ id,
+ col_a,
+ col_b,
+ col_c,
+ col_d,
+ filler
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500,
+ col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100,
+ col_c = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 250,
+ col_d = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 50,
+ filler = REPLICATE('x', 100)
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+INSERT INTO
+ dbo.ic_key_include_test
+(
+ id,
+ col_a,
+ col_b,
+ filler
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500,
+ col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100,
+ filler = REPLICATE('x', 100)
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
GO
CREATE INDEX ix_hot ON dbo.ic_min_reads_test (col_a) INCLUDE (col_b);
@@ -178,6 +430,64 @@
CREATE INDEX ix_w2 ON dbo.ic_min_writes_test (col_a) INCLUDE (col_c);
CREATE INDEX ix_never_read ON dbo.ic_unused_test (col_a);
+
+/* Key duplicate of the nonclustered PK: same key, different includes -> Rule 5 */
+CREATE INDEX ix_pk_key_dupe ON dbo.ic_pk_key_test (id) INCLUDE (col_b);
+
+/* Exact duplicate of the nonclustered PK, named to sort BEFORE it */
+CREATE UNIQUE INDEX aa_ix_pk_exact_dupe ON dbo.ic_pk_exact_test (id);
+
+/* Identical but for the direction of col_b, and with different includes */
+CREATE INDEX ix_sort_asc ON dbo.ic_sort_dir_test (col_a, col_b) INCLUDE (col_c);
+CREATE INDEX ix_sort_desc ON dbo.ic_sort_dir_test (col_a, col_b DESC) INCLUDE (col_d);
+
+/* Subset whose include (col_b) is already a key column of the superset */
+CREATE INDEX ix_ki_subset ON dbo.ic_key_include_test (col_a) INCLUDE (col_b);
+CREATE INDEX ix_ki_superset ON dbo.ic_key_include_test (col_a, col_b);
+GO
+
+/*
+Drive reads so no index below is left cold enough for Rule 1 to claim it as
+unused on a long-uptime server, which would make the absence assertions pass
+for the wrong reason.
+
+ix_sort_asc gets seeks AND scans (+200 +100) while ix_sort_desc gets seeks only
+(+200). That gap is what makes ix_sort_desc the deterministic loser if the two
+ever pair up, rather than leaving a priority tie to resolve arbitrarily.
+*/
+DECLARE
+ @c bigint,
+ @i integer = 0;
+
+WHILE @i < 10
+BEGIN
+ /* PK key-duplicate pair: both sides read */
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_key_test AS t WITH (INDEX = pk_ic_pk_key_test) WHERE t.id = @i OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_key_test AS t WITH (INDEX = ix_pk_key_dupe) WHERE t.id = @i OPTION(MAXDOP 1);
+
+ /* PK exact-duplicate pair: one seek each, so index_priority ties */
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_exact_test AS t WITH (INDEX = zz_pk_ic_pk_exact_test) WHERE t.id = @i OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_exact_test AS t WITH (INDEX = aa_ix_pk_exact_dupe) WHERE t.id = @i OPTION(MAXDOP 1);
+
+ /* Sort-direction pair: seeks on both */
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_sort_dir_test AS t WITH (INDEX = ix_sort_asc) WHERE t.col_a = @i OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_sort_dir_test AS t WITH (INDEX = ix_sort_desc) WHERE t.col_a = @i OPTION(MAXDOP 1);
+
+ /* Key-subset/superset pair: reads on both */
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_key_include_test AS t WITH (INDEX = ix_ki_subset) WHERE t.col_a = @i OPTION(MAXDOP 1);
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_key_include_test AS t WITH (INDEX = ix_ki_superset) WHERE t.col_a = @i OPTION(MAXDOP 1);
+
+ SELECT @i += 1;
+END;
+
+/* Scans for ix_sort_asc only, so it outranks ix_sort_desc */
+SELECT @i = 0;
+
+WHILE @i < 3
+BEGIN
+ SELECT @c = COUNT_BIG(*) FROM dbo.ic_sort_dir_test AS t WITH (INDEX = ix_sort_asc) WHERE t.col_c >= 0 OPTION(MAXDOP 1);
+ SELECT @i += 1;
+END;
GO
/*
@@ -230,8 +540,145 @@
DROP TABLE IF EXISTS dbo.ic_min_reads_test;
DROP TABLE IF EXISTS dbo.ic_min_writes_test;
DROP TABLE IF EXISTS dbo.ic_unused_test;
+DROP TABLE IF EXISTS dbo.ic_pk_key_test;
+DROP TABLE IF EXISTS dbo.ic_pk_exact_test;
+DROP TABLE IF EXISTS dbo.ic_sort_dir_test;
+DROP TABLE IF EXISTS dbo.ic_key_include_test;
"""
+# Group F's fixture. Unlike everything above it, this one needs two databases:
+# the defect only exists across iterations of the @get_all_databases loop.
+#
+# CrapA MUST be created before CrapB. The procedure's database cursor is
+# ORDER BY database_id, NOT by name, so processing order is CREATION order and
+# the names are a convenience rather than the mechanism. CrapA therefore has to
+# be the first database processed, so that its rows are the stale ones still
+# sitting in #index_analysis when CrapB's pass truncates #index_details out from
+# under them. run_tests asserts that this actually held rather than trusting it.
+#
+# CrapA gets PRIMARY KEY NONCLUSTERED (col_a) + UNIQUE (col_a) + ix_mdb (col_a).
+# The unique constraint is what gives Rule 7.5 something to replace, which is
+# what leaves a MAKE UNIQUE winner (ix_mdb) behind in #index_analysis. Without it
+# there is no winner for the stale PK to pair with and the bug cannot fire.
+#
+# CrapB only has to exist, contain the table so the loop does real work for it,
+# and be processed after CrapA. Its own indexes are uninteresting; the second
+# iteration itself is the point.
+#
+# Both databases are pre-dropped so a run that died before its cleanup cannot
+# leave a half-built fixture that quietly changes what is being tested.
+MDB_SETUP_SQL = """
+SET NOCOUNT ON;
+GO
+
+IF DB_ID('%(first)s') IS NOT NULL
+BEGIN
+ ALTER DATABASE %(first)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE %(first)s;
+END;
+GO
+
+IF DB_ID('%(second)s') IS NOT NULL
+BEGIN
+ ALTER DATABASE %(second)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE %(second)s;
+END;
+GO
+
+/* Creation order is processing order. This one has to come first. */
+CREATE DATABASE %(first)s;
+GO
+
+CREATE DATABASE %(second)s;
+GO
+
+USE %(first)s;
+GO
+
+/*
+The unique constraint is the load-bearing part: Rule 7.5 replaces it with ix_mdb
+and marks ix_mdb MAKE UNIQUE, and that leftover winner is what the stale primary
+key used to get paired against on the next database's pass.
+*/
+CREATE TABLE
+ dbo.mdb_test
+(
+ col_a integer NOT NULL,
+ col_b integer NOT NULL,
+ CONSTRAINT pk_mdb PRIMARY KEY NONCLUSTERED (col_a),
+ CONSTRAINT uq_mdb UNIQUE (col_a)
+);
+
+INSERT INTO
+ dbo.mdb_test
+(
+ col_a,
+ col_b
+)
+SELECT TOP (20000)
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_b = 1
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+CREATE INDEX ix_mdb ON dbo.mdb_test (col_a);
+GO
+
+USE %(second)s;
+GO
+
+/*
+Deliberately plain. All this database has to do is exist and be processed after
+CrapA, so that a second loop iteration happens at all.
+*/
+CREATE TABLE
+ dbo.mdb_test
+(
+ col_a integer NOT NULL,
+ col_b integer NOT NULL
+);
+
+INSERT INTO
+ dbo.mdb_test
+(
+ col_a,
+ col_b
+)
+SELECT TOP (20000)
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_b = 1
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+CREATE INDEX ix_mdb_b ON dbo.mdb_test (col_a);
+GO
+""" % {"first": MDB_DATABASE_FIRST, "second": MDB_DATABASE_SECOND}
+
+# Runs unconditionally, including when the fixture failed to build or an
+# assertion failed. Leaving stray databases on the instance is not acceptable,
+# so this takes them SINGLE_USER first rather than letting a stray session block
+# the drop and leak the database.
+MDB_CLEANUP_SQL = """
+SET NOCOUNT ON;
+GO
+
+IF DB_ID('%(first)s') IS NOT NULL
+BEGIN
+ ALTER DATABASE %(first)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE %(first)s;
+END;
+GO
+
+IF DB_ID('%(second)s') IS NOT NULL
+BEGIN
+ ALTER DATABASE %(second)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE %(second)s;
+END;
+GO
+""" % {"first": MDB_DATABASE_FIRST, "second": MDB_DATABASE_SECOND}
+
def run_sqlcmd(server, password, input_file=None, query=None,
database=TEST_DATABASE, timeout=600):
@@ -251,19 +698,33 @@ def run_sqlcmd(server, password, input_file=None, query=None,
return result.stdout, result.stderr
-def run_sql_script(server, password, sql, timeout=600):
+def run_sql_script(server, password, sql, database=TEST_DATABASE, timeout=600):
"""Run a multi-batch script (one containing GO) through a temp file."""
path = None
try:
handle, path = tempfile.mkstemp(suffix=".sql", prefix="ic_rule_cov_")
with os.fdopen(handle, "w") as f:
f.write(sql)
- return run_sqlcmd(server, password, input_file=path, timeout=timeout)
+ return run_sqlcmd(server, password, input_file=path,
+ database=database, timeout=timeout)
finally:
if path and os.path.exists(path):
os.remove(path)
+def sql_errors(stdout, stderr):
+ """
+ Every SQL error worth failing a fixture over, from both streams.
+
+ go-sqlcmd reports SQL errors on stdout, and severities 17-19 matter as much
+ as 16 -- an earlier version of this looked for the literal "Level 16" on
+ stderr only, which is how adversarial_test.sql managed to fail on every run
+ for months while the suite stayed green.
+ """
+ return (re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stdout or "") +
+ re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stderr or ""))
+
+
def parse_output(stdout):
"""
Parse sp_IndexCleanup tab-delimited output into rows.
@@ -354,6 +815,25 @@ def get_uptime_days(server, password):
raise RuntimeError("Could not read server uptime:\n%s" % stdout)
+def get_database_id(server, password, database_name):
+ """
+ A database's database_id, or None if it does not exist.
+
+ Group F needs this because the procedure's database cursor is
+ ORDER BY database_id: the id, not the name, is what decides which database
+ the loop processes first.
+ """
+ stdout, _ = run_sqlcmd(
+ server, password, database="master",
+ query="SET NOCOUNT ON; SELECT database_id = DB_ID('%s');" % database_name,
+ )
+ for line in stdout.split("\n"):
+ line = line.strip()
+ if line.isdigit():
+ return int(line)
+ return None
+
+
def run_proc(server, password, table_name, extra="", debug=False):
"""Run sp_IndexCleanup scoped to one table and return (rows, stdout)."""
query = (
@@ -365,6 +845,24 @@ def run_proc(server, password, table_name, extra="", debug=False):
return parse_output(stdout), stdout
+def run_proc_all_databases(server, password, table_name):
+ """
+ Run sp_IndexCleanup across every database on the instance, scoped to one
+ table, and return (rows, stdout).
+
+ @get_all_databases = 1 is the entire point: it is the only way to make the
+ procedure loop, and the loop is what Group F is testing. @table_name keeps it
+ cheap -- every database without that table is dismissed immediately.
+ """
+ query = (
+ "EXECUTE master.dbo.sp_IndexCleanup "
+ "@get_all_databases = 1, @schema_name = 'dbo', @table_name = '%s';"
+ % table_name
+ )
+ stdout, _ = run_sqlcmd(server, password, database="master", query=query)
+ return parse_output(stdout), stdout
+
+
def run_tests(server, password, uptime_days):
"""Run all assertions and return results."""
results = []
@@ -483,6 +981,236 @@ def assert_test(group, name, condition, detail=""):
assert_test("B-MinWrites", "@min_reads = 50 alone: ix_w1 still gets COMPRESSION SCRIPT",
len(matches) == 1, "found %d" % len(matches))
+ # ---- Group C: nonclustered PRIMARY KEYs are constraint-backed ----
+ #
+ # A PK cannot be rebuilt via DROP_EXISTING with different includes (Msg 1907)
+ # and must never be disabled (it succeeds, and takes every inbound foreign key
+ # down with it). is_primary_key is not is_unique_constraint -- the latter is 0
+ # for a primary key -- so a PK needs its own protection everywhere a unique
+ # constraint gets it.
+
+ # C1: PK with a key duplicate. Nothing may target the PK for a rebuild.
+ rows, _ = run_proc(server, password, "ic_pk_key_test")
+
+ pk = "pk_ic_pk_key_test"
+
+ merge_rows = find_rows(rows, index_name=pk, script_type="MERGE SCRIPT")
+ assert_test("C-PrimaryKey", "PK with a key duplicate gets no MERGE SCRIPT",
+ len(merge_rows) == 0,
+ "found %d (any is CREATE UNIQUE INDEX ... DROP_EXISTING vs a PK -> Msg 1907)"
+ % len(merge_rows))
+
+ # Nothing may name the PK in ANY dedupe action, merge or disable.
+ pk_dedupe = dedupe_rows(rows, pk)
+ assert_test("C-PrimaryKey", "PK with a key duplicate gets no dedupe action at all",
+ len(pk_dedupe) == 0,
+ "found %s" % [r["script_type"] for r in pk_dedupe])
+
+ # No OTHER index may claim the PK as its merge/disable target either: that is
+ # the half that silently drops a covering column when the rebuild fails.
+ targeting = [
+ r for r in rows
+ if r.get("target_index_name") == pk
+ and r.get("script_type") in DEDUPE_SCRIPT_TYPES
+ ]
+ assert_test("C-PrimaryKey", "no script targets the PK as a consolidation winner",
+ len(targeting) == 0,
+ "found %s" % [(r["index_name"], r["script_type"]) for r in targeting])
+
+ # Positive control: proving the PK is protected is only meaningful if the
+ # procedure actually looked at it. Compression is the proof it is still in
+ # play and was not made invisible to fix the bug above.
+ matches = find_rows(rows, index_name=pk, script_type="COMPRESSION SCRIPT")
+ assert_test("C-PrimaryKey", "positive control: PK still gets COMPRESSION SCRIPT",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # C2: PK with an exact duplicate, priorities tied, PK sorting later. This is
+ # the case that used to emit ALTER INDEX [pk...] DISABLE -- a script that
+ # executes cleanly and silently turns off referential integrity.
+ rows, _ = run_proc(server, password, "ic_pk_exact_test")
+
+ pk = "zz_pk_ic_pk_exact_test"
+
+ disable_rows = find_rows(rows, index_name=pk, script_type="DISABLE SCRIPT")
+ assert_test("C-PrimaryKey", "PK with an exact duplicate gets no DISABLE SCRIPT",
+ len(disable_rows) == 0,
+ "found %d (a PK DISABLE succeeds and disables inbound FKs)"
+ % len(disable_rows))
+
+ pk_dedupe = dedupe_rows(rows, pk)
+ assert_test("C-PrimaryKey", "PK with an exact duplicate gets no dedupe action at all",
+ len(pk_dedupe) == 0,
+ "found %s" % [r["script_type"] for r in pk_dedupe])
+
+ matches = find_rows(rows, index_name=pk, script_type="COMPRESSION SCRIPT")
+ assert_test("C-PrimaryKey", "positive control: tied PK still gets COMPRESSION SCRIPT",
+ len(matches) == 1, "found %d" % len(matches))
+
+ # ---- Group D: sort direction is part of an index's identity ----
+ #
+ # Rule 5 has no is_descending_key comparison of its own. The ' DESC' embedded
+ # in key_columns is the entire reason (col_a, col_b) and (col_a, col_b DESC)
+ # do not hash alike, and a false Key Duplicate here disables a real index
+ # with a script that runs without complaint.
+ rows, _ = run_proc(server, password, "ic_sort_dir_test")
+
+ for name in ("ix_sort_asc", "ix_sort_desc"):
+ actions = dedupe_rows(rows, name)
+ assert_test("D-SortDirection",
+ "%s: ASC/DESC pair is not a duplicate (no dedupe action)" % name,
+ len(actions) == 0,
+ "found %s" % [(r["script_type"], r["consolidation_rule"])
+ for r in actions])
+
+ # Positive control: both were analyzed, so the absence above is the direction
+ # check holding rather than the indexes never being considered.
+ for name in ("ix_sort_asc", "ix_sort_desc"):
+ matches = find_rows(rows, index_name=name)
+ assert_test("D-SortDirection", "positive control: %s was analyzed" % name,
+ len(matches) >= 1,
+ "found %d rows (%s)"
+ % (len(matches), [m["script_type"] for m in matches]))
+
+ # ---- Group E: a merged index may not INCLUDE its own key column ----
+ #
+ # Rule 6 merges a subset's includes into the superset that absorbs it. When
+ # the subset's include is already a KEY of the superset, keeping it emits
+ # CREATE INDEX (col_a, col_b) INCLUDE (col_b), which SQL Server rejects with
+ # Msg 1909 -- while the paired DISABLE of the subset still runs.
+ rows, _ = run_proc(server, password, "ic_key_include_test")
+
+ merge = find_rows(rows, index_name="ix_ki_superset", script_type="MERGE SCRIPT")
+ assert_test("E-KeyInclude", "positive control: the superset gets a MERGE SCRIPT",
+ len(merge) == 1, "found %d" % len(merge))
+
+ if merge:
+ script = merge[0].get("script") or ""
+ include_part = script.split("INCLUDE", 1)[1] if "INCLUDE" in script else ""
+ assert_test("E-KeyInclude",
+ "merged superset does not INCLUDE its own key column col_b",
+ "[col_b]" not in include_part,
+ "INCLUDE clause was '%s' in: %s"
+ % (include_part.strip()[:60], script[:110]))
+
+ # And the subset really was disabled in its favor, so the merge above is the
+ # live code path rather than a rule that never fired.
+ subset = find_rows(rows, index_name="ix_ki_subset", script_type="DISABLE SCRIPT")
+ assert_test("E-KeyInclude", "positive control: the key subset is disabled",
+ len(subset) == 1, "found %d" % len(subset))
+
+ # ---- Group F: #index_details is per-database, #index_analysis is not ----
+ #
+ # Under @get_all_databases, #index_details is TRUNCATED for each database in
+ # the loop while #index_analysis ACCUMULATES across all of them for the final
+ # output, so every rule re-runs over rows belonging to databases that were
+ # already processed.
+ #
+ # Rules 2/3/5/7 survive it: each requires EXISTS (#index_details ...
+ # is_eligible_for_dedupe = 1), which finds nothing for a stale row and fails
+ # CLOSED. Rule 7.6 had only NOT EXISTS (... is_unique_constraint = 1), which
+ # passes VACUOUSLY once the table is empty -- so on CrapB's pass it paired
+ # CrapA's leftover MAKE UNIQUE winner with CrapA's primary key and marked the
+ # PK DISABLE. The script-generation backstops read #index_details too, so
+ # they passed vacuously in turn and the script shipped:
+ #
+ # ALTER INDEX [pk_mdb] ON [CrapA].[dbo].[mdb_test] DISABLE;
+ #
+ # which runs cleanly and silently disables every inbound foreign key.
+ rows, _ = run_proc_all_databases(server, password, MDB_TABLE)
+
+ # Positive controls FIRST, because every assertion in this group is an
+ # assertion of absence and each control below is a precondition the bug needs
+ # in order to fire at all. If any of them stops holding, "no DISABLE named the
+ # PK" becomes true for a reason that has nothing to do with the guard, and
+ # this group would pass while testing nothing.
+
+ # F-PC1: CrapA has to be processed BEFORE CrapB or there is no stale-row pass.
+ # The cursor is ORDER BY database_id, not by name, so this is creation order.
+ # Asserted rather than assumed: the names sorting the "right" way is a
+ # coincidence, not the mechanism, and nothing would announce it if it flipped.
+ id_first = get_database_id(server, password, MDB_DATABASE_FIRST)
+ id_second = get_database_id(server, password, MDB_DATABASE_SECOND)
+ assert_test("F-MultiDatabase",
+ "positive control: %s is processed before %s"
+ % (MDB_DATABASE_FIRST, MDB_DATABASE_SECOND),
+ id_first is not None
+ and id_second is not None
+ and id_first < id_second,
+ "database_id %s=%s %s=%s (cursor is ORDER BY database_id)"
+ % (MDB_DATABASE_FIRST, id_first, MDB_DATABASE_SECOND, id_second))
+
+ # F-PC2: CrapA's table was really analyzed and its PK is visible to the
+ # procedure. This is the control that stops "no DISABLE named the PK" from
+ # passing because the fixture never built or the database was never reached.
+ # It is also the half that keeps the fix honest: protecting the PK must not
+ # be implemented by making primary keys invisible.
+ pk_compression = find_rows(rows, database_name=MDB_DATABASE_FIRST,
+ index_name=MDB_PK, script_type="COMPRESSION SCRIPT")
+ assert_test("F-MultiDatabase",
+ "positive control: %s.%s gets a COMPRESSION SCRIPT"
+ % (MDB_DATABASE_FIRST, MDB_PK),
+ len(pk_compression) == 1, "found %d" % len(pk_compression))
+
+ # F-PC3: Rule 7.5 fired and left a MAKE UNIQUE winner behind. This is the
+ # precondition that matters most. The bug pairs a stale PK against exactly
+ # this winner, so if the unique constraint stopped being replaced there would
+ # be nothing to pair with and the absence assertions below would be vacuous.
+ winner = find_rows(rows, database_name=MDB_DATABASE_FIRST, index_name="ix_mdb",
+ script_type="MERGE SCRIPT",
+ consolidation_rule__like="Unique Constraint Replacement")
+ assert_test("F-MultiDatabase",
+ "positive control: ix_mdb is the MAKE UNIQUE winner (Rule 7.5 fired)",
+ len(winner) == 1,
+ "found %d -- without this winner the bug has nothing to pair a "
+ "stale PK against" % len(winner))
+
+ # F-PC4: CrapB was actually reached, so the loop genuinely took a second
+ # iteration. One database processed means #index_details is never truncated
+ # out from under anything and the whole scenario evaporates.
+ second_db = find_rows(rows, database_name=MDB_DATABASE_SECOND)
+ assert_test("F-MultiDatabase",
+ "positive control: %s was reached (a second loop iteration happened)"
+ % MDB_DATABASE_SECOND,
+ len(second_db) >= 1,
+ "found %d rows for %s (%s)"
+ % (len(second_db), MDB_DATABASE_SECOND,
+ [r["index_name"] for r in second_db]))
+
+ # The assertions. Not scoped to a database: a primary key must not be
+ # disabled in ANY of them, and the bug's victim is whichever database the
+ # loop happened to process first.
+ disable_rows = find_rows(rows, index_name=MDB_PK, script_type="DISABLE SCRIPT")
+ assert_test("F-MultiDatabase",
+ "no DISABLE SCRIPT names the primary key, in any database",
+ len(disable_rows) == 0,
+ "found %d (a PK DISABLE succeeds and disables inbound FKs) %s"
+ % (len(disable_rows),
+ [(r["database_name"], r["consolidation_rule"]) for r in disable_rows]))
+
+ # Broader than the DISABLE above: no dedupe action of any kind may name the
+ # PK. A MERGE against a constraint-backed index is Msg 1907, and a DISABLE
+ # CONSTRAINT would drop the key outright.
+ pk_dedupe = dedupe_rows(rows, MDB_PK)
+ assert_test("F-MultiDatabase",
+ "no dedupe action of any kind targets the primary key",
+ len(pk_dedupe) == 0,
+ "found %s"
+ % [(r["database_name"], r["script_type"], r["consolidation_rule"])
+ for r in pk_dedupe])
+
+ # And nothing may claim the PK as its consolidation winner either -- the
+ # other direction of the same pairing.
+ targeting = [
+ r for r in rows
+ if r.get("target_index_name") == MDB_PK
+ and r.get("script_type") in DEDUPE_SCRIPT_TYPES
+ ]
+ assert_test("F-MultiDatabase",
+ "no script targets the primary key as a consolidation winner",
+ len(targeting) == 0,
+ "found %s" % [(r["database_name"], r["index_name"], r["script_type"])
+ for r in targeting])
+
return results
@@ -520,18 +1248,44 @@ def main():
print("Building synthetic fixture in %s..." % TEST_DATABASE)
stdout, stderr = run_sql_script(server, password, SETUP_SQL)
- if "Msg " in stdout or ("Msg " in stderr and "Level 16" in stderr):
+ # Same check the other harnesses use.
+ errors = sql_errors(stdout, stderr)
+
+ if errors:
print("ERROR: SQL errors during fixture setup:")
- print(stdout[-2000:])
- print(stderr[:500] if stderr else "(empty stderr)")
+ for e in errors:
+ print(" " + e)
+ print()
+ print("The fixture did not build, so the assertions below would be")
+ print("testing something other than what they claim.")
sys.exit(1)
+ print("Building multi-database fixture (%s, %s) for Group F..."
+ % (MDB_DATABASE_FIRST, MDB_DATABASE_SECOND))
print()
+ # Everything from here down is inside the try. The databases must come off
+ # this instance whatever happens next -- a failed assertion, a failed fixture
+ # build, or an exception nobody predicted. sys.exit raises SystemExit, so the
+ # error path below unwinds through the same finally as a passing run does.
try:
+ stdout, stderr = run_sql_script(server, password, MDB_SETUP_SQL,
+ database="master")
+ errors = sql_errors(stdout, stderr)
+
+ if errors:
+ print("ERROR: SQL errors during multi-database fixture setup:")
+ for e in errors:
+ print(" " + e)
+ print()
+ print("Group F's fixture did not build, so its assertions of absence")
+ print("would pass for the wrong reason.")
+ sys.exit(1)
+
results = run_tests(server, password, uptime_days)
finally:
run_sql_script(server, password, CLEANUP_SQL)
+ run_sql_script(server, password, MDB_CLEANUP_SQL, database="master")
# Report
passed = sum(1 for r in results if r["passed"])
From 4e09823f743b3490cc5163bc1fdc808044e3e48b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 12:11:59 -0400
Subject: [PATCH 20/60] sp_IndexCleanup: scope per-database work to
@current_database_id
Fixes two @get_all_databases blockers, both succeed-while-wrong: a generated
script that executes cleanly and quietly destroys index coverage or referential
integrity.
#index_analysis accumulates across the database cursor because the final result
set is built from it after the loop finishes. Every other working temp table -
#index_details, #partition_stats, #compression_eligibility, #merged_includes and
the rest - is truncated at the top of each iteration. So on database N+1's pass,
every rule and script-generation statement re-ran over database N's leftover
rows while the per-database context tables held N+1's data:
1. Include lists were stripped from MERGE scripts. The include-merge recompute
read an empty #index_details for a stale row, returned NULL, and overwrote
included_columns. The merge-script insert then emitted a second row for the
index with no INCLUDE, no DATA_COMPRESSION, no ON [filegroup]; the two rows
tie on the final ROW_NUMBER, so which one a user saw was a coin flip.
Executed, the stripped pair leaves the winner with no includes and its
duplicate disabled - every covering column on the table gone, no error.
2. ALTER INDEX DISABLE was emitted. The DISABLE-script
insert's constraint exclusion is a NOT EXISTS against #index_details, which
passes vacuously for a stale row because the table was truncated. It
succeeds, disables inbound foreign keys, and orphan rows insert afterward.
Both are one systemic pattern - a statement re-running over stale rows whose
context tables are gone, where NOT EXISTS guards fail open and recomputes return
NULL. The earlier Rule 7.6 fix hardened one instance of it; this scopes the
cause. Every in-loop statement that touches #index_analysis now filters to
@current_database_id (42 predicates). The scope is free: scope_hash already
encodes database plus object and every rule joins on it, so no rule can
legitimately pair rows across databases - for two-alias joins, scoping the
driven side is enough. Statements that roll up across all databases run after
the loop and are not scoped. A note at the top of the cursor loop states the
invariant once.
With staleness handled at the source, Rule 7.6's EXISTS/is_eligible_for_dedupe
predicate is no longer the cross-database guard its comment claimed - that
comment was opaque precisely because the protection was a side effect. The
predicate stays (it is the same eligibility check Rules 2/3/5/7 make, keeping
primary keys and other ineligible rows out), and its comment now says only that.
Tests: rule_coverage_test.py 32 -> 40. New Group G builds two databases and
asserts, with six positive controls, that no merge script is missing its INCLUDE
list and no ALTER INDEX ... DISABLE names a unique constraint. Proven 38/2 red
against the pre-scope build (both bug assertions fail, all positive controls
pass) and 40/0 with the fix.
Verified on 2016/2017/2019/2022/2025: compiles clean; single-database output is
byte-identical to the prior build across 8 invocations (scoping is a no-op
there); both blockers proven fixed with independent two-database repros;
run_tests.py 32/32; fixture_cases_test.py 31/31; rule_coverage_test.py 40/40;
no_access_test.py 4/4.
Co-Authored-By: Claude Opus 4.8
---
sp_IndexCleanup/sp_IndexCleanup.sql | 104 +++++--
sp_IndexCleanup/tests/rule_coverage_test.py | 299 +++++++++++++++++++-
2 files changed, 381 insertions(+), 22 deletions(-)
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index aeb5d174..f918a8dd 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -1610,6 +1610,32 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
RAISERROR('Generating #filtered_object insert', 0, 0) WITH NOWAIT;
END;
+ /*
+ One pass per database. Read this before touching anything inside the loop.
+
+ #index_analysis is NOT truncated between iterations - it accumulates every
+ database's rows because the final result set is built from it after the
+ cursor finishes. Every OTHER working temp table (#index_details,
+ #partition_stats, #compression_eligibility, #merged_includes and the rest,
+ reset just below) holds only the CURRENT database.
+
+ So any statement in this loop that reads or writes #index_analysis must
+ filter to ia.database_id = @current_database_id. Without it the statement
+ also processes rows for databases already handled, whose context tables have
+ since been truncated - and that produced silent, executable, WRONG DDL:
+ merge scripts stripped of their INCLUDE lists (the recompute read an empty
+ #index_details and returned NULL) and ALTER INDEX ... DISABLE against unique
+ constraints (a NOT EXISTS guard passed vacuously against the empty table).
+ Both ship a script that runs cleanly and quietly destroys index coverage or
+ referential integrity.
+
+ The scope is free in single-database runs and semantically free in general:
+ scope_hash already encodes database plus object and every rule joins on it,
+ so no rule can legitimately pair rows across databases anyway. For joins
+ between two #index_analysis aliases, scoping the driven side is enough - the
+ join carries the partner along. Statements that intentionally roll up across
+ ALL databases live AFTER the loop and are not scoped.
+ */
WHILE @@FETCH_STATUS = 0
BEGIN
/*Truncate temp tables between database iterations*/
@@ -3425,6 +3451,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THEN 50 /* Indexes with includes get priority over those without */
ELSE 0
END /* Prefer indexes with included columns */
+ WHERE #index_analysis.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -3464,6 +3491,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id.is_eligible_for_dedupe = 1 /* Only eligible indexes */
)
AND #index_analysis.index_id <> 1 /* Don't disable clustered indexes */
+ AND #index_analysis.database_id = @current_database_id
OPTION(RECOMPILE);
END;
@@ -3532,6 +3560,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
)
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
SET @rc = ROWCOUNT_BIG();
@@ -3620,6 +3649,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id1.is_descending_key <> id2.is_descending_key
/* Different sort direction */
)
+ AND ia1.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -3750,6 +3780,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id2.index_hash = ia2.index_hash /* Specific index from ia2 */
AND id1.is_descending_key <> id2.is_descending_key /* Different sort direction */
)
+ AND ia1.database_id = @current_database_id
OPTION(RECOMPILE);
DECLARE @rule3_rowcount bigint = ROWCOUNT_BIG();
@@ -3785,7 +3816,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia2.consolidation_rule = N'Key Subset'
AND ia2.action = N'DISABLE'
AND ia2.target_index_name IS NOT NULL
- AND ia1.target_index_name <> ia2.target_index_name;
+ AND ia1.target_index_name <> ia2.target_index_name
+ AND ia1.database_id = @current_database_id;
SET @chains_resolved = ROWCOUNT_BIG();
END;
@@ -3824,6 +3856,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE ia1.consolidation_rule = N'Key Subset'
AND ia1.action = N'DISABLE'
AND ia2.consolidation_rule IS NULL /* Not already processed */
+ AND ia2.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -3923,6 +3956,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id2.index_hash = ia2.index_hash
AND id2.is_eligible_for_dedupe = 1
)
+ AND ia1.database_id = @current_database_id
OPTION(RECOMPILE);
DECLARE @rule5_rowcount bigint = ROWCOUNT_BIG();
@@ -4041,6 +4075,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
FROM #index_analysis AS superset
WHERE superset.action = N'MERGE INCLUDES'
AND superset.consolidation_rule = N'Key Superset'
+ AND superset.database_id = @current_database_id
OPTION(RECOMPILE);
/* Apply the pre-computed merged includes */
@@ -4052,6 +4087,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
JOIN #merged_includes AS mi
ON mi.scope_hash = ia.scope_hash
AND mi.index_name = ia.index_name
+ WHERE ia.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -4106,6 +4142,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia1_check.consolidation_rule = N'Key Subset'
AND ia1_check.action = N'DISABLE'
)
+ AND ia2.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -4215,6 +4252,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id2_inner.is_included_column = 0
)
)
+ AND ia1.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -4284,6 +4322,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id_nc.index_hash = ia_nc.index_hash
AND id_nc.is_unique_constraint = 1
)
+ AND ia_uc.database_id = @current_database_id
OPTION(RECOMPILE);
/* Second, mark nonclustered indexes to be made unique */
@@ -4326,6 +4365,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/* Check that both indexes have EXACTLY the same key columns */
AND ia_uc.key_columns = ia_nc.key_columns
)
+ AND ia_nc.database_id = @current_database_id
OPTION(RECOMPILE);
/* CRITICAL: Ensure that only the unique constraints that exactly match get this treatment */
@@ -4349,6 +4389,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia_uc.action = N'DISABLE'
AND ia_uc.target_index_name = ia.index_name
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Make sure the nonclustered index has the superseded_by field set correctly */
@@ -4369,6 +4410,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia_uc.action = N'DISABLE'
AND ia_uc.target_index_name = ia_nc.index_name
WHERE ia_nc.action = N'MAKE UNIQUE'
+ AND ia_nc.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -4474,6 +4516,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id_fk.is_foreign_key_reference = 1
AND id_fk.is_included_column = 0
)
+ AND ia_loser.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -4513,24 +4556,11 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id_uc.is_unique_constraint = 1
)
/*
- Both sides must still be present in #index_details and eligible, exactly as
- Rules 2, 3, 5 and 7 require. This is not redundant bookkeeping.
-
- Under @get_all_databases, #index_details is truncated for each database
- (see the reset list above) while #index_analysis accumulates across all of
- them for the final output. Every rule then re-runs over rows belonging to
- databases already processed. The sibling rules fall out harmlessly because
- their EXISTS against #index_details finds nothing for those stale rows.
- This rule had only a NOT EXISTS, which passes VACUOUSLY once the table is
- empty - so it paired a previous database's leftover MAKE UNIQUE winner with
- that database's backfilled primary key and marked the PK DISABLE. The
- script-generation backstops read #index_details too, so they passed
- vacuously in turn and an ALTER INDEX [pk...] DISABLE shipped: it executes
- cleanly, silently disables every inbound foreign key, and orphan rows insert
- afterward with no error.
-
- An EXISTS is the correct shape here precisely because it fails closed on a
- stale row, where a NOT EXISTS fails open.
+ Both sides must be present in #index_details and eligible for dedupe, the
+ same check Rules 2, 3, 5 and 7 make: a row still in #index_analysis whose
+ index is a primary key or is otherwise ineligible must not be picked up here.
+ Cross-database staleness is handled by the @current_database_id scope below,
+ not by this predicate.
*/
AND EXISTS
(
@@ -4548,6 +4578,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id_win.index_hash = ia_winner.index_hash
AND id_win.is_eligible_for_dedupe = 1
)
+ /*
+ Only rows for the database being processed. #index_analysis accumulates
+ across the whole cursor for the final output, so without this every rule
+ re-runs over already-processed databases whose per-database context tables
+ (#index_details and the rest) have since been truncated - which produced
+ silent, wrong DDL. See the note at the top of the cursor loop.
+ */
+ AND ia_dup.database_id = @current_database_id
OPTION(RECOMPILE);
/* Merge includes from the disabled Key Duplicates into the MAKE UNIQUE index */
@@ -4696,6 +4734,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia_loser.consolidation_rule = N'Key Duplicate'
AND ia_loser.target_index_name = ia_winner.index_name
)
+ AND ia_winner.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -4774,6 +4813,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE id1.index_hash = ia1.index_hash
AND id2.index_hash = ia2.index_hash
)
+ AND ia1.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -4977,6 +5017,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
FROM #index_analysis AS ia
WHERE ia.action = N'MERGE INCLUDES'
AND ia.consolidation_rule = N'Key Duplicate'
+ AND ia.database_id = @current_database_id
GROUP BY
ia.database_id,
ia.object_id,
@@ -5007,6 +5048,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE ia.index_name <> kdd.winning_index_name
AND ia.action = N'MERGE INCLUDES'
AND ia.consolidation_rule = N'Key Duplicate'
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Update the winning index's superseded_by to list all other indexes */
@@ -5025,6 +5067,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ON ia.scope_hash = kdd.scope_hash
AND ia.key_filter_hash = kdd.key_filter_hash
WHERE ia.index_name = kdd.winning_index_name
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Merge all included columns from Key Duplicate indexes into the winning index */
@@ -5102,6 +5145,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE kdd.scope_hash = ia.scope_hash
AND kdd.winning_index_name = ia.index_name
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Insert Key Duplicate winners into #merged_includes so they get MERGE scripts */
@@ -5144,6 +5188,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND loser.included_columns IS NOT NULL
AND ISNULL(loser.included_columns, N'') <> ISNULL(ia.included_columns, N'')
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/*
@@ -5256,6 +5301,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND loser_check.consolidation_rule = N'Key Duplicate'
AND loser_check.included_columns IS NOT NULL
)
+ AND ia.database_id = @current_database_id
) AS kd
WHERE ISNULL(kd.merged_includes, N'') <> ISNULL(kd.original_includes, N'')
OPTION(RECOMPILE);
@@ -5275,6 +5321,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE ia.action = N'MERGE INCLUDES'
AND ia.consolidation_rule = N'Key Duplicate'
AND ISNULL(ia.included_columns, N'') <> ISNULL(mi.merged_includes, N'')
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
IF @debug = 1
@@ -5333,6 +5380,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
OR ia2.included_columns IS NULL
OR LEN(ia1.included_columns) < LEN(ia2.included_columns)
)
+ WHERE ia1.database_id = @current_database_id
OPTION(RECOMPILE);
/* Update the subset indexes to be disabled, since supersets already contain their columns */
@@ -5351,6 +5399,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
JOIN #include_subset_dedupe AS isd
ON ia.scope_hash = isd.scope_hash
AND ia.index_name = isd.subset_index_name
+ WHERE ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Update the superset indexes to indicate they supersede the subset indexes */
@@ -5370,6 +5419,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
JOIN #include_subset_dedupe AS isd
ON ia.scope_hash = isd.scope_hash
AND ia.index_name = isd.superset_index_name
+ WHERE ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Update winning indexes that don't actually need changes to have action = N'KEEP' */
@@ -5390,6 +5440,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE mi.scope_hash = ia.scope_hash
AND mi.index_name = ia.index_name
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Insert merge scripts for indexes */
@@ -5603,6 +5654,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND CHARINDEX(ps_uq_guard.partition_columns, ia.key_columns) = 0
)
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Debug which indexes are getting MERGE scripts */
@@ -5813,6 +5865,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia_unique.index_name = ia.index_name
AND ia_unique.action = N'MAKE UNIQUE'
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Add clustered indexes to #index_analysis specifically for compression purposes */
@@ -6073,6 +6126,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
OR id.is_primary_key = 1
)
AND ce.can_compress = 1 /* Only those eligible for compression */
+ AND fo.database_id = @current_database_id
/* Only add if not already in #index_analysis */
AND NOT EXISTS
(
@@ -6091,7 +6145,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SET
#index_analysis.action = N'KEEP'
WHERE #index_analysis.index_id = 1 /* Clustered indexes */
- AND #index_analysis.action IS NULL;
+ AND #index_analysis.action IS NULL
+ AND #index_analysis.database_id = @current_database_id;
/* Update index priority for clustered indexes to ensure they're not chosen for deduplication */
UPDATE
@@ -6099,7 +6154,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SET
#index_analysis.index_priority = 1000 /* Maximum priority */
WHERE #index_analysis.index_id = 1 /* Clustered indexes */
- AND #index_analysis.index_priority IS NULL;
+ AND #index_analysis.index_priority IS NULL
+ AND #index_analysis.database_id = @current_database_id;
IF @debug = 1
BEGIN
@@ -6232,6 +6288,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(ia.action IS NULL OR ia.action = N'KEEP')
/* Only indexes eligible for compression */
AND ce.can_compress = 1
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Insert disable scripts for unique constraints */
@@ -6307,6 +6364,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ia_uc.action = N'DISABLE'
/* That have consolidation_rule of 'Unique Constraint Replacement' */
AND ia_uc.consolidation_rule = N'Unique Constraint Replacement'
+ AND ia_uc.database_id = @current_database_id
OPTION(RECOMPILE);
/* Insert per-partition compression scripts */
@@ -6432,6 +6490,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND (ia.action IS NULL OR ia.action = N'KEEP')
/* Only indexes eligible for compression */
AND ce.can_compress = 1
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/* Insert compression ineligible info */
@@ -6495,6 +6554,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id.is_included_column = 0 /* Get only one row per index */
AND id.key_ordinal > 0
WHERE ce.can_compress = 0
+ AND ce.database_id = @current_database_id
OPTION(RECOMPILE);
@@ -6558,6 +6618,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND id.is_included_column = 0 /* Get only one row per index */
AND id.key_ordinal > 0
WHERE ia.action = N'REVIEW'
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
@@ -6714,6 +6775,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
AND ia.index_id > 0
)
)
+ AND ia.database_id = @current_database_id
OPTION(RECOMPILE);
/*
diff --git a/sp_IndexCleanup/tests/rule_coverage_test.py b/sp_IndexCleanup/tests/rule_coverage_test.py
index 89668e92..06661af6 100644
--- a/sp_IndexCleanup/tests/rule_coverage_test.py
+++ b/sp_IndexCleanup/tests/rule_coverage_test.py
@@ -146,6 +146,18 @@
MDB_TABLE = "mdb_test"
MDB_PK = "pk_mdb"
+# Group G reuses the very same CrapA/CrapB (CrapA processed first is exactly the
+# property both groups need) but adds two of its own tables. The two defects it
+# covers live in SEPARATE tables analyzed in SEPARATE @table_name scoped runs on
+# purpose: with both in one table the unique-constraint rules reshuffle the merge
+# dedup and the include strip stops being deterministic. See MDB_G_SETUP_SQL.
+MDB_MERGE_TABLE = "icg_merge" # superset/subset include-merge pair (bug 1)
+MDB_MERGE_WINNER = "ix_merge_super" # the Key Superset that must keep its INCLUDE
+MDB_MERGE_SUBSET = "ix_merge_sub" # the subset it absorbs and disables
+MDB_UC_TABLE = "icg_uc" # unique constraint + same-key index (bug 2)
+MDB_UC_CONSTRAINT = "uq_icg" # the constraint that must not get ALTER..DISABLE
+MDB_UC_PLAIN = "ix_uc_plain" # the index promoted to replace it
+
# Rows that represent a dedupe action. A COMPRESSION SCRIPT row is not one.
DEDUPE_SCRIPT_TYPES = ("DISABLE SCRIPT", "MERGE SCRIPT", "DISABLE CONSTRAINT SCRIPT")
@@ -680,6 +692,161 @@
""" % {"first": MDB_DATABASE_FIRST, "second": MDB_DATABASE_SECOND}
+# Group G's fixture. Runs AFTER MDB_SETUP_SQL has created CrapA and CrapB, and
+# just adds two tables to each. Kept separate from MDB_SETUP_SQL so Group F's
+# narrative stays about mdb_test alone; the shared teardown (MDB_CLEANUP_SQL drops
+# both databases) already carries these tables away.
+#
+# The bug material goes in CrapA because it is processed first, so its rows are
+# the stale ones still sitting in #index_analysis when CrapB's pass truncates
+# #index_details. CrapB gets plain copies so the loop takes a real second
+# iteration for whichever table each scoped run targets.
+#
+# icg_merge: a Key Superset (col_a, col_b) INCLUDE (col_c) over a subset
+# (col_a) INCLUDE (col_d). Rule 4/6 merges the subset's col_d into the superset,
+# whose correct merged script is INCLUDE (col_c, col_d). On CrapB's pass the
+# include-merge recomputes CrapA's superset from an emptied #index_details, gets
+# NULL, and overwrites it -- the merge-script insert then emits a stripped row
+# with no INCLUDE that can win the final ROW_NUMBER tie.
+#
+# icg_uc: a UNIQUE constraint uq_icg (col_a, id) with a same-key plain index
+# ix_uc_plain. Rule 7.5 promotes the index to replace the constraint and drops
+# the constraint. On CrapB's pass the DISABLE-script insert's
+# NOT EXISTS (#index_details ... is_unique_constraint = 1) guard passes vacuously
+# for stale uq_icg and emits ALTER INDEX [uq_icg] ... DISABLE on top of the
+# correct DROP CONSTRAINT.
+#
+# No modulo (%) anywhere in the DDL: this string is %-formatted for the database
+# names, so a literal % would be read as a format specifier.
+MDB_G_SETUP_SQL = """
+SET NOCOUNT ON;
+GO
+
+USE %(first)s;
+GO
+
+/* Bug 1: superset/subset include-merge pair. */
+CREATE TABLE
+ dbo.icg_merge
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_b integer NOT NULL,
+ col_c integer NOT NULL,
+ col_d integer NOT NULL,
+ CONSTRAINT pk_icg_merge PRIMARY KEY CLUSTERED (id)
+);
+
+INSERT INTO
+ dbo.icg_merge
+(
+ id,
+ col_a,
+ col_b,
+ col_c,
+ col_d
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_b = 1,
+ col_c = 2,
+ col_d = 3
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+CREATE INDEX ix_merge_super ON dbo.icg_merge (col_a, col_b) INCLUDE (col_c);
+CREATE INDEX ix_merge_sub ON dbo.icg_merge (col_a) INCLUDE (col_d);
+GO
+
+/* Bug 2: unique constraint + same-key plain index. */
+CREATE TABLE
+ dbo.icg_uc
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_c integer NOT NULL,
+ CONSTRAINT pk_icg_uc PRIMARY KEY CLUSTERED (id)
+);
+
+INSERT INTO
+ dbo.icg_uc
+(
+ id,
+ col_a,
+ col_c
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_c = 1
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+ALTER TABLE dbo.icg_uc ADD CONSTRAINT uq_icg UNIQUE (col_a, id);
+CREATE INDEX ix_uc_plain ON dbo.icg_uc (col_a, id) INCLUDE (col_c);
+GO
+
+USE %(second)s;
+GO
+
+/* Plain copies so the second iteration does real work for the scoped table. */
+CREATE TABLE
+ dbo.icg_merge
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_b integer NOT NULL
+);
+
+INSERT INTO
+ dbo.icg_merge
+(
+ id,
+ col_a,
+ col_b
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = 1,
+ col_b = 2
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+CREATE INDEX ix_merge_b ON dbo.icg_merge (col_a);
+GO
+
+CREATE TABLE
+ dbo.icg_uc
+(
+ id integer NOT NULL,
+ col_a integer NOT NULL,
+ col_c integer NOT NULL
+);
+
+INSERT INTO
+ dbo.icg_uc
+(
+ id,
+ col_a,
+ col_c
+)
+SELECT TOP (20000)
+ id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)),
+ col_a = 1,
+ col_c = 2
+FROM sys.all_columns AS ac1
+CROSS JOIN sys.all_columns AS ac2
+OPTION(MAXDOP 1);
+
+CREATE INDEX ix_uc_b ON dbo.icg_uc (col_a);
+GO
+""" % {"first": MDB_DATABASE_FIRST, "second": MDB_DATABASE_SECOND}
+
+
def run_sqlcmd(server, password, input_file=None, query=None,
database=TEST_DATABASE, timeout=600):
"""Run SQL from a file or a query string and capture output."""
@@ -1211,6 +1378,122 @@ def assert_test(group, name, condition, detail=""):
"found %s" % [(r["database_name"], r["index_name"], r["script_type"])
for r in targeting])
+ # ---- Group G: include-merge scripts and unique-constraint DISABLE survive the loop ----
+ #
+ # Same defect class as Group F -- per-database temp tables are truncated each
+ # iteration while #index_analysis accumulates, so every rule and every
+ # script-generation statement re-runs over rows belonging to databases
+ # already processed. Group F caught the primary-key DISABLE. These are two
+ # other ways it surfaced before the per-database scoping fix, both in CrapA
+ # (processed first, so its rows are the stale ones during CrapB's pass):
+ #
+ # Bug 1: the include-merge machinery recomputes CrapA's superset winner from
+ # an emptied #index_details on CrapB's pass, gets NULL, and overwrites its
+ # merged includes. The merge-script insert then emits a SECOND, stripped row
+ # (no INCLUDE, no DATA_COMPRESSION, no ON [filegroup]) and the final
+ # ROW_NUMBER ties it against the good one. Run, the stripped pair rebuilds
+ # the winner with every covering column gone while its subset is disabled --
+ # and it executes without error.
+ #
+ # Bug 2: the DISABLE-script insert excludes unique constraints with
+ # NOT EXISTS (#index_details ... is_unique_constraint = 1), which passes
+ # VACUOUSLY for a stale row whose #index_details was truncated. So CrapA's
+ # uq_icg got ALTER INDEX ... DISABLE in addition to its correct
+ # DROP CONSTRAINT -- and ALTER INDEX DISABLE on a unique constraint's index
+ # silently disables every inbound foreign key.
+ #
+ # Every assertion below is an assertion of absence, so each is preceded by
+ # positive controls: the rule fired, the winner was produced, and CrapB was
+ # reached. Without those a green result could just mean the fixture never
+ # built or the rule silently stopped matching.
+
+ # --- Bug 1: the include-merge winner must keep its INCLUDE list ---
+ merge_rows, _ = run_proc_all_databases(server, password, MDB_MERGE_TABLE)
+
+ # G-PC1: the merge winner was produced at all (Rule 4/6 fired for CrapA). If
+ # it were not, "no merge script is missing its INCLUDE" would pass vacuously.
+ merge_winner = find_rows(merge_rows, database_name=MDB_DATABASE_FIRST,
+ index_name=MDB_MERGE_WINNER, script_type="MERGE SCRIPT")
+ assert_test("G-MultiDatabase",
+ "positive control: %s.%s gets a MERGE SCRIPT (include-merge fired)"
+ % (MDB_DATABASE_FIRST, MDB_MERGE_WINNER),
+ len(merge_winner) == 1, "found %d" % len(merge_winner))
+
+ # G-PC2: its subset really was disabled -- the other half of the pair whose
+ # includes the merge is supposed to absorb. No subset, no merge to strip.
+ merge_subset = find_rows(merge_rows, database_name=MDB_DATABASE_FIRST,
+ index_name=MDB_MERGE_SUBSET, script_type="DISABLE SCRIPT")
+ assert_test("G-MultiDatabase",
+ "positive control: %s.%s (the subset) is disabled"
+ % (MDB_DATABASE_FIRST, MDB_MERGE_SUBSET),
+ len(merge_subset) == 1, "found %d" % len(merge_subset))
+
+ # G-PC3: CrapB was reached for this table, so the loop took a real second
+ # iteration and #index_details was truncated out from under CrapA's rows.
+ merge_second = find_rows(merge_rows, database_name=MDB_DATABASE_SECOND)
+ assert_test("G-MultiDatabase",
+ "positive control: %s was reached for %s (a second iteration happened)"
+ % (MDB_DATABASE_SECOND, MDB_MERGE_TABLE),
+ len(merge_second) >= 1,
+ "found %d rows for %s" % (len(merge_second), MDB_DATABASE_SECOND))
+
+ # The assertion. The surviving merge script must still carry an INCLUDE. The
+ # stripped row the bug produced has the INCLUDE clause gone entirely, so any
+ # merge row for the winner whose script has no INCLUDE is the defect.
+ merge_stripped = [
+ r for r in merge_winner
+ if "include" not in r.get("script", "").lower()
+ ]
+ assert_test("G-MultiDatabase",
+ "no merge script for %s is missing its INCLUDE list, in any database"
+ % MDB_MERGE_WINNER,
+ len(merge_stripped) == 0,
+ "found %d stripped (winner rebuilt without its covering columns): %s"
+ % (len(merge_stripped), [r.get("script") for r in merge_stripped]))
+
+ # --- Bug 2: a unique constraint must never get ALTER INDEX ... DISABLE ---
+ uc_rows, _ = run_proc_all_databases(server, password, MDB_UC_TABLE)
+
+ # G-PC4: the constraint really was replaced -- it gets its correct
+ # DROP CONSTRAINT. This is what makes the absence assertion meaningful:
+ # uq_icg WAS in play as a droppable constraint, the tool just must not ALSO
+ # ALTER INDEX it.
+ uc_drop = find_rows(uc_rows, database_name=MDB_DATABASE_FIRST,
+ index_name=MDB_UC_CONSTRAINT,
+ script_type="DISABLE CONSTRAINT SCRIPT")
+ assert_test("G-MultiDatabase",
+ "positive control: %s.%s gets a DISABLE CONSTRAINT SCRIPT"
+ % (MDB_DATABASE_FIRST, MDB_UC_CONSTRAINT),
+ len(uc_drop) == 1, "found %d" % len(uc_drop))
+
+ # G-PC5: the plain index that replaces it was promoted (Rule 7.5 fired). This
+ # is the winner the stale constraint used to get paired against.
+ uc_winner = find_rows(uc_rows, database_name=MDB_DATABASE_FIRST,
+ index_name=MDB_UC_PLAIN, script_type="MERGE SCRIPT")
+ assert_test("G-MultiDatabase",
+ "positive control: %s.%s is the MAKE UNIQUE replacement (Rule 7.5 fired)"
+ % (MDB_DATABASE_FIRST, MDB_UC_PLAIN),
+ len(uc_winner) == 1, "found %d" % len(uc_winner))
+
+ # G-PC6: CrapB was reached for this table too.
+ uc_second = find_rows(uc_rows, database_name=MDB_DATABASE_SECOND)
+ assert_test("G-MultiDatabase",
+ "positive control: %s was reached for %s (a second iteration happened)"
+ % (MDB_DATABASE_SECOND, MDB_UC_TABLE),
+ len(uc_second) >= 1,
+ "found %d rows for %s" % (len(uc_second), MDB_DATABASE_SECOND))
+
+ # The assertion. A unique CONSTRAINT must never be named by an
+ # ALTER INDEX ... DISABLE (a DISABLE SCRIPT row), in any database.
+ uc_disable = find_rows(uc_rows, index_name=MDB_UC_CONSTRAINT,
+ script_type="DISABLE SCRIPT")
+ assert_test("G-MultiDatabase",
+ "no DISABLE SCRIPT names the unique constraint, in any database",
+ len(uc_disable) == 0,
+ "found %d (ALTER INDEX DISABLE on a UC silently disables inbound FKs) %s"
+ % (len(uc_disable),
+ [(r["database_name"], r["script"]) for r in uc_disable]))
+
return results
@@ -1260,7 +1543,7 @@ def main():
print("testing something other than what they claim.")
sys.exit(1)
- print("Building multi-database fixture (%s, %s) for Group F..."
+ print("Building multi-database fixture (%s, %s) for Groups F and G..."
% (MDB_DATABASE_FIRST, MDB_DATABASE_SECOND))
print()
@@ -1282,6 +1565,20 @@ def main():
print("would pass for the wrong reason.")
sys.exit(1)
+ # Group G's tables ride on the same CrapA/CrapB the block above created.
+ stdout, stderr = run_sql_script(server, password, MDB_G_SETUP_SQL,
+ database="master")
+ errors = sql_errors(stdout, stderr)
+
+ if errors:
+ print("ERROR: SQL errors during Group G fixture setup:")
+ for e in errors:
+ print(" " + e)
+ print()
+ print("Group G's fixture did not build, so its assertions of absence")
+ print("would pass for the wrong reason.")
+ sys.exit(1)
+
results = run_tests(server, password, uptime_days)
finally:
run_sql_script(server, password, CLEANUP_SQL)
From 7cfebd99b757305a1f5d7de5f0b6016bf10a8d4d Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 13:59:32 -0400
Subject: [PATCH 21/60] Remove dead ROLLBACK boilerplate from three read-only
procedures
sp_QueryReproBuilder, sp_PerfCheck, and sp_HumanEvents each carried
IF @@TRANCOUNT > 0 ROLLBACK in their CATCH block - the standard error-handling
template - but none of them opens a transaction of its own (zero BEGIN
TRANSACTION anywhere, dynamic SQL included). Without a BEGIN TRANSACTION every
statement autocommits, so @@TRANCOUNT > 0 in the CATCH can only ever be the
CALLER's transaction. On any internal error these procedures would silently roll
back work the caller was in the middle of, having changed nothing themselves.
It also actively masked errors: calling sp_QueryReproBuilder via INSERT ...
EXECUTE raised Msg 3915 ("Cannot use the ROLLBACK statement within an INSERT-EXEC
statement") from the boilerplate instead of surfacing the real error (a plain
Msg 213 column-count mismatch, which now comes through).
Five sibling read-only procs already carry no ROLLBACK - sp_QuickieStore (which
sp_QueryReproBuilder was built from), sp_HealthParser, sp_LogHunter,
sp_PressureDetector, and sp_QuickieCache - so this brings the three stragglers
into line with the rest of the suite. Each CATCH keeps its diagnostics and THROW;
sp_HumanEvents keeps its Extended Events session cleanup. A comment in each
records why the ROLLBACK must not come back.
Verified on 2016/2017/2019/2022/2025: all three compile clean; sp_PerfCheck runs
a full health check without error; sp_QueryReproBuilder still generates a repro
and no longer raises Msg 3915 under INSERT-EXEC; @help runs on all three.
Co-Authored-By: Claude Opus 4.8
---
sp_HumanEvents/sp_HumanEvents.sql | 9 ++++++---
sp_PerfCheck/sp_PerfCheck.sql | 12 +++++++-----
sp_QueryReproBuilder/sp_QueryReproBuilder.sql | 15 ++++++++++-----
3 files changed, 23 insertions(+), 13 deletions(-)
diff --git a/sp_HumanEvents/sp_HumanEvents.sql b/sp_HumanEvents/sp_HumanEvents.sql
index e595d741..4ab2dd8b 100644
--- a/sp_HumanEvents/sp_HumanEvents.sql
+++ b/sp_HumanEvents/sp_HumanEvents.sql
@@ -5300,9 +5300,12 @@ END TRY
/*Very professional error handling*/
BEGIN CATCH
BEGIN
- IF @@TRANCOUNT > 0
- ROLLBACK TRANSACTION;
-
+ /*
+ No ROLLBACK. This procedure manages Extended Events sessions via DDL,
+ which autocommits, and opens no transaction of its own - so a ROLLBACK
+ here could only unwind the CALLER's transaction on an internal error.
+ The session cleanup below is the error handling that actually matters.
+ */
/*Only try to drop a session if we're not outputting*/
IF (@output_database_name = N''
AND @output_schema_name IN (N'', N'dbo'))
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index bd3c6296..1d0a70f1 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -5191,11 +5191,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
r.check_id;
END TRY
BEGIN CATCH
- IF @@TRANCOUNT > 0
- BEGIN
- ROLLBACK;
- END;
-
+ /*
+ No ROLLBACK. This procedure only reads diagnostics and builds a result
+ set - it opens no transaction of its own, so a ROLLBACK here could only
+ unwind the CALLER's transaction on an internal error, destroying work it
+ had no part in. That is the standard error-handling template misapplied
+ to a read-only procedure.
+ */
THROW;
END CATCH;
END;
diff --git a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
index 34d2c6be..12776e15 100644
--- a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
+++ b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
@@ -5129,11 +5129,16 @@ BEGIN CATCH
RAISERROR('%s', 10, 1, @sql) WITH NOWAIT;
END;
- IF @@TRANCOUNT > 0
- BEGIN
- ROLLBACK;
- END;
-
+ /*
+ No ROLLBACK here on purpose. This procedure only reads Query Store and builds
+ strings - it opens no transaction of its own (there is no BEGIN TRANSACTION
+ anywhere in it), so a ROLLBACK could only ever unwind the CALLER's
+ transaction. That is the standard error-handling template misapplied to a
+ read-only procedure: on any internal error it would silently destroy work the
+ caller was in the middle of. It also broke INSERT ... EXECUTE against this
+ proc, where the ROLLBACK raised Msg 3915 instead of surfacing the real error.
+ sp_QuickieStore, which this was built from, carries no ROLLBACK either.
+ */
THROW;
END CATCH;
From 9ae491444392e8fd5d60f3bf61b7a1c412fc0908 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 14:58:52 -0400
Subject: [PATCH 22/60] sp_QueryReproBuilder: add a generate-and-execute test
harness
sp_QueryReproBuilder had no tests. It emits a runnable reproduction script, so its
characteristic failure is the same one that dogged sp_IndexCleanup: a repro that
reads correctly but does not execute, or executes but is silently wrong. Neither
compiling nor eyeballing catches that - only running the generated script does.
run_tests.py feeds 44 query plans through @query_plan_xml (the Query Store bypass,
a deterministic entry point), extracts each emitted repro, and EXECUTES it inside
a rolled-back transaction. 237 assertions. Plans are embedded as constants with
authentic ParameterCompiledValue serializations captured from a live instance
(numeric(38,2) -> (12.50), money -> ($99.9500), datetimeoffset with an offset,
guids, scientific-notation floats), so the suite is fully self-contained - no
plan-cache capture, no external fixtures. The one case needing a real user table
creates and drops a tempdb fixture itself.
Coverage: parameterized plans (explicit params, @1..@12, a 399-parameter plan),
no-parameter plans, scaled/precision types, entity characters (& < > ') in query
text and in parameter values, apostrophes, unicode, statement text past 4000 and
8000 characters, and the warned '?' fill-in (a parameter present in the text but
absent from the plan's ParameterList - asserted to warn and fail loud, never a
silent wrong value).
mutation_check.py proves the suite has teeth: it plants five plausible generation
bugs (value-list misalignment, every declared type forced to int, scaled type
split on its internal comma, statement apostrophes not doubled, the '?' path
emitting NULL) and asserts the harness goes red on each, then restores the real
build. Two of the five - the value misalignment and the apostrophe doubling - are
caught ONLY because the repros are executed; no static check would see them, which
is the whole argument for the execute check.
README documents the scripts, the execute-check rationale, and the known warned
'?' behavior. Does not touch sp_QueryReproBuilder.sql. Verified: run_tests.py
237/237 on the current build; mutation_check.py 5/5 caught with the real build
restored.
Co-Authored-By: Claude Opus 4.8
---
sp_QueryReproBuilder/tests/README.md | 145 ++++
sp_QueryReproBuilder/tests/mutation_check.py | 198 +++++
sp_QueryReproBuilder/tests/run_tests.py | 807 ++++++++++++++++++
.../tests/template_execute.sql | 40 +
.../tests/template_generate.sql | 31 +
5 files changed, 1221 insertions(+)
create mode 100644 sp_QueryReproBuilder/tests/README.md
create mode 100644 sp_QueryReproBuilder/tests/mutation_check.py
create mode 100644 sp_QueryReproBuilder/tests/run_tests.py
create mode 100644 sp_QueryReproBuilder/tests/template_execute.sql
create mode 100644 sp_QueryReproBuilder/tests/template_generate.sql
diff --git a/sp_QueryReproBuilder/tests/README.md b/sp_QueryReproBuilder/tests/README.md
new file mode 100644
index 00000000..e2da88e5
--- /dev/null
+++ b/sp_QueryReproBuilder/tests/README.md
@@ -0,0 +1,145 @@
+# sp_QueryReproBuilder Tests
+
+`sp_QueryReproBuilder` reads a query plan and emits a **runnable** reproduction
+script -- the T-SQL in `#repro_queries.executable_query`, surfaced in the primary
+result set keyed `table_name = 'results'`. The generated repro is the whole
+product. Compiling the procedure proves nothing about it; a repro that reads
+correctly but will not run is worse than useless, because someone will paste it
+into a window expecting it to work.
+
+So the suite here does not stop at "a repro came back." It feeds a plan in,
+pulls the emitted repro out, and **actually executes it**, because many of the
+ways this generator can go wrong are semantic, not syntactic, and only surface
+when the script runs.
+
+**Run the suite before and after any change to `sp_QueryReproBuilder.sql`.**
+
+```
+cd sp_QueryReproBuilder/tests
+python run_tests.py --server SQL2022
+python mutation_check.py --server SQL2022
+```
+
+Both take `--server` and `--password` (default `SQL2022` / the standard local sa
+password). Expect `237 passed, 0 failed` and `5 of 5 mutations caught`.
+
+## What is here
+
+| File | What it does |
+| --- | --- |
+| `run_tests.py` | 44 cases, 237 assertions. Each embeds a ShowPlanXML, drives it through `@query_plan_xml`, extracts the emitted repro, executes it, and asserts it built, is correct, and RAN. Self-contained and self-cleaning. |
+| `mutation_check.py` | Plants five plausible generation bugs in a scratch copy of the procedure, installs each, and asserts `run_tests.py` goes RED on every one -- proof the suite has teeth. Restores the real build afterward. |
+| `template_generate.sql` | The generation half: runs the procedure in `@query_plan_xml` mode and lets its result set print so the repro can be read off stdout. Driven by `run_tests.py`. |
+| `template_execute.sql` | The execution half: takes the repro back (as base64) into a real `nvarchar(max)` variable and runs it with `sys.sp_executesql` inside `BEGIN TRANSACTION ... ROLLBACK` and `TRY/CATCH`. Driven by `run_tests.py`. |
+
+Everything is embedded or synthesized. There is no dependency on a captured plan
+cache, on `StackOverflow2013`, or on any particular user database. Plans
+reference `sys` objects, which are always present; the single case that needs a
+real user table (a parameterized `UPDATE`) uses a small fixture the harness
+creates in `tempdb` and drops on the way out.
+
+## The execute check is the one that earns its keep
+
+Every generated repro is run for real, each in its own rolled-back transaction,
+via `sys.sp_executesql` on the repro held in a variable. Three things about that
+are deliberate:
+
+- **It runs, it does not just parse.** `SET PARSEONLY ON` would sail past a repro
+ that parses but cannot bind -- a parameter declared with the wrong type, a
+ value that will not convert. Those are the characteristic bugs of a generator
+ like this, and only running the script catches them.
+- **The repro lives in a variable, executed with `sp_executesql`.** That defers
+ its compile, so a broken repro (say, an unbalanced quote from a botched
+ apostrophe-doubling change) is a *catchable* error reported as
+ `EXEC_RESULT: FAIL`, not a batch-killing parse error that takes the harness
+ down with it. Pasting the repro verbatim into a batch would misclassify that
+ same bug as an uncatchable syntax error of a different severity.
+- **The hand-off is base64.** `run_tests.py` passes the repro back as base64 of
+ its utf-16-le bytes, so there is no quote-escaping to get wrong and no
+ 8000-character string-literal limit to trip over on the long-repro cases.
+
+The generation half deliberately does **not** use `INSERT ... EXECUTE`.
+`sp_QueryReproBuilder` returns different result-set shapes -- the wide `results`
+set when a repro is built versus the single-column `#repro_queries is empty`
+diagnostic when nothing lands -- and a fixed-shape `INSERT ... EXECUTE` target
+cannot absorb both. The repro is read off stdout instead, from the
+`executable_query` processing instruction that renders as ``.
+
+## Coverage
+
+The battery carries over the assertions that matter and holds each generated
+repro to both "is it correct?" and "does it run?":
+
+- **Parameterized plans** -- explicit `sp_executesql`-style parameters, and the
+ many-parameter (`@1..@12`, 399-parameter) cases.
+- **No-parameter plans.**
+- **Scaled / precision types**, each with the *authentic* `ParameterCompiledValue`
+ serialization captured from real sniffed plans: `numeric(38,2)` as `(12.50)`,
+ `decimal(38,10)`, `money` as `($99.9500)`, `float`/`real` in scientific
+ notation, `varchar(8000)`, `nvarchar(max)`, `datetime2(7)`,
+ `datetimeoffset(7)` (note the ` +05:30` with its leading space), `datetime`,
+ `date`, `time(7)`, `uniqueidentifier` as `{guid'...'}`, `varbinary(8)`, `bit`,
+ `bigint`.
+- **Entity characters** (`&` `<` `>` `'`) in statement text and in a parameter's
+ compiled value.
+- **Apostrophes** in both a parameterized statement (must be re-doubled inside
+ the outer `N'...'`) and a raw statement.
+- **Unicode / N-literals** (round-tripped through the repro, not just declared).
+- **Long statement text** both `> 4000` and `> 8000` characters, plus a long
+ parameterized statement `> 8000`.
+- **Parameter/value alignment** -- declaration order versus ParameterList order,
+ asserting value *i* binds to declaration *i*.
+- **Control characters** (CR, LF) embedded in a parameter's sniffed value.
+- **Echo cases** that execute the repro and read back the *actually-bound*
+ values, catching a silent reorder or misbind that still runs.
+
+### The documented `?` fill-in behavior
+
+When a parameter is declared in the query text but is **absent from the plan's
+ParameterList**, the procedure cannot know its value. It does the safe thing: it
+sets the value to `?` and emits a warning ("... were not found in the plan
+ParameterList ..."). The repro then fails **loud** (`Msg 102` near `?`) if run
+as-is, rather than silently executing with a wrong value.
+
+The suite asserts exactly that contract: the warning fires, the `?` placeholder
+is present, and execution does **not** silently pass. This is the intended
+behavior, not a bug -- you are meant to fill in the value before running. The
+`mutation_check.py` `M6` mutation (making that path emit `NULL` instead of `?`)
+proves the assertion has teeth: it would let the repro run silently with a wrong
+value, and the suite catches it.
+
+The same safe degradation covers the **first-ParameterList-wins** hazard: when a
+decoy `ParameterList` (as XML-reader / UDF operators emit) precedes the query's
+own, the procedure's substring extraction grabs the decoy, the real parameter is
+lost, and the case falls back to the warned `?` path -- loud failure, not a
+silent wrong result. That is asserted (`decoy:decoy_first_degrades_loud`) rather
+than papered over.
+
+## mutation_check.py -- proving the teeth
+
+A green suite is only worth trusting if it goes red when the generator breaks.
+`mutation_check.py` plants five bugs, one at a time, in a scratch copy of the
+procedure (it **never** edits the repo file), installs each mutant, runs
+`run_tests.py`, and asserts it goes red:
+
+| Mutation | Broken behavior | Caught by |
+| --- | --- | --- |
+| `M1` | value list re-sorted independently of the declaration list | echo cases (wrong bound values / bind error) |
+| `M2` | every parameter's declared type forced to `int` | type round-trip assertions **and** execution (numeric/date/binary values will not bind to `int`) |
+| `M3` | fill-in path splits scaled types like `numeric(10,2)` on the internal comma | the `?` scaled-fill-in case (`numeric(10,2)` no longer intact in the declaration) |
+| `M4` | statement-apostrophe doubling removed | execution (broken outer `N'...'` literal) |
+| `M6` | `?` fill-in emitted as `NULL` | the `?`-path assertions (silent run instead of loud fail) |
+
+`M4` and `M1` are caught **only** because the repros are executed -- there is no
+static check that would notice them -- which is the whole argument for the
+execute check. After the run, `mutation_check.py` restores the real build from
+`sp_QueryReproBuilder.sql` and leaves it installed. Run `--dry` to verify the
+mutation patterns still anchor to the current procedure (each must match exactly
+once) after editing it.
+
+## Not wired into CI
+
+Like the `sp_IndexCleanup` suite, these run by hand against a live instance.
+They install the procedure and execute generated SQL, so they need a real SQL
+Server, not a syntax check. Run them yourself before shipping a change to
+`sp_QueryReproBuilder.sql`.
diff --git a/sp_QueryReproBuilder/tests/mutation_check.py b/sp_QueryReproBuilder/tests/mutation_check.py
new file mode 100644
index 00000000..c4d171af
--- /dev/null
+++ b/sp_QueryReproBuilder/tests/mutation_check.py
@@ -0,0 +1,198 @@
+"""
+sp_QueryReproBuilder harness mutation check
+===========================================
+Proves run_tests.py has teeth. A green test suite is only worth trusting if it
+goes RED when the thing it tests is broken, so this planted-bug check:
+
+ 1. reads the REAL procedure from the repo (never edits that file),
+ 2. applies each plausible generation bug to a scratch copy,
+ 3. installs the mutant into master on the target,
+ 4. runs run_tests.py and asserts it goes RED (the mutation is CAUGHT), then
+ 5. restores the real build from the repo file and leaves it installed.
+
+The mutations mirror the three failure modes the task calls out plus one more:
+ M1 - value list re-sorted independently of the declaration list
+ (silent parameter/value misbinding)
+ M2 - every parameter's declared type forced to int
+ M4 - statement-apostrophe doubling removed (broken outer N'...' literal)
+ M6 - the '?' fill-in placeholder emitted as NULL (would silently run with a
+ wrong value instead of failing loud)
+
+Usage:
+ python mutation_check.py [--server SQL2022] [--password L!nt0044] [--dry]
+
+--dry only verifies each pattern still matches the current procedure exactly
+once (run it after editing sp_QueryReproBuilder.sql to catch anchor drift).
+Exits 1 if any mutation SURVIVES (a coverage hole) or the real build cannot be
+restored.
+"""
+
+import argparse
+import atexit
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO_PROC = os.path.normpath(os.path.join(HERE, "..", "sp_QueryReproBuilder.sql"))
+RUN_TESTS = os.path.join(HERE, "run_tests.py")
+
+WORKDIR = tempfile.mkdtemp(prefix="qrb_mut_")
+atexit.register(lambda: shutil.rmtree(WORKDIR, ignore_errors=True))
+
+# Each mutation: (name, pattern, replacement, why it must be caught).
+# Every pattern must match the current procedure exactly once; --dry checks that.
+MUTATIONS = [
+ ("M1_value_list_misalign",
+ r"(ISNULL\s*\(\s*qp\.parameter_compiled_value,\s*N'NULL'\s*\)\s*"
+ r"FROM #query_parameters AS qp\s*WHERE qp\.plan_id = qsp\.plan_id\s*ORDER BY\s*)"
+ r"qp\.parameter_name",
+ r"\1qp.parameter_compiled_value DESC",
+ "value list re-sorted independently of the decl list -> silent misbinding"),
+
+ ("M2_decl_type_forced_int",
+ r"qp\.parameter_name\s*\+\s*N' '\s*\+\s*qp\.parameter_data_type",
+ r"qp.parameter_name + N' ' + N'int'",
+ "every parameter declared int -> wrong type for numeric/nvarchar/etc."),
+
+ ("M4_no_stmt_apostrophe_doubling",
+ r"REPLACE\s*\(\s*clean_query\.query_text_cleaned,\s*N'''',\s*N''''''\s*\)",
+ r"clean_query.query_text_cleaned",
+ "statement apostrophes not doubled -> broken outer N'...' literal"),
+
+ ("M6_qmark_becomes_null",
+ r"(parameter_compiled_value =\s*)N'\?'(\s*FROM #query_store_plan)",
+ r"\1N'NULL'\2",
+ "missing params default to NULL and silently execute instead of ?"),
+
+ ("M3_scaled_type_split",
+ r"REPLACE\(prefix\.param_prefix, N',@', N'
@'\)",
+ r"REPLACE(prefix.param_prefix, N',', N'
')",
+ "fill-in path splits scaled types like numeric(10,2) on the internal comma"),
+]
+
+
+def read_proc():
+ with open(REPO_PROC, "r", encoding="utf-8-sig") as f:
+ return f.read()
+
+
+def pattern_check(base):
+ print("Pattern match check (each must match exactly once):")
+ all_ok = True
+ for (name, pat, _repl, _why) in MUTATIONS:
+ n = len(re.findall(pat, base, re.DOTALL))
+ ok = n == 1
+ all_ok = all_ok and ok
+ print(" %-32s matches=%d %s" % (name, n, "OK" if ok else "!! DRIFTED"))
+ return all_ok
+
+
+def install(path, server, password, label):
+ r = subprocess.run(
+ ["sqlcmd", "-S", server, "-U", "sa", "-P", password, "-d", "master",
+ "-i", path, "-b"],
+ capture_output=True, text=True, timeout=180)
+ if r.returncode != 0:
+ print(" install(%s) FAILED rc=%d: %s"
+ % (label, r.returncode, (r.stdout + r.stderr).strip()[:300]))
+ return False
+ return True
+
+
+def run_harness(server, password):
+ """Run run_tests.py; return (failed_count, ok) where ok is False if the
+ run could not be parsed."""
+ r = subprocess.run(
+ [sys.executable, RUN_TESTS, "--server", server, "--password", password],
+ capture_output=True, text=True, timeout=900)
+ m = re.search(r"Results:\s*(\d+) passed,\s*(\d+) failed", r.stdout)
+ if not m:
+ return None, r.stdout[-800:]
+ return int(m.group(2)), None
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--server", default="SQL2022")
+ ap.add_argument("--password", default="L!nt0044")
+ ap.add_argument("--dry", action="store_true")
+ args = ap.parse_args()
+
+ base = read_proc()
+
+ all_ok = pattern_check(base)
+ if args.dry:
+ sys.exit(0 if all_ok else 1)
+ if not all_ok:
+ print("\nAborting: a mutation pattern no longer matches the procedure "
+ "exactly once. Fix the anchors before trusting this check.")
+ sys.exit(1)
+
+ mutant_path = os.path.join(WORKDIR, "_mutant.sql")
+ results = []
+ baseline_failed = None
+
+ try:
+ print("\nInstalling the real build (baseline)...")
+ if not install(REPO_PROC, args.server, args.password, "repo"):
+ print("Could not install the real build; aborting.")
+ sys.exit(1)
+ baseline_failed, err = run_harness(args.server, args.password)
+ if baseline_failed is None:
+ print("Could not parse baseline harness output:\n" + (err or ""))
+ sys.exit(1)
+ print("Baseline: %d failed (expected 0)." % baseline_failed)
+ if baseline_failed != 0:
+ print("Baseline is not green; fix run_tests.py before mutating.")
+ sys.exit(1)
+
+ print("\nApplying mutations:")
+ for (name, pat, repl, why) in MUTATIONS:
+ mutant = re.sub(pat, repl, base, count=1, flags=re.DOTALL)
+ if mutant == base:
+ results.append((name, "NOT_APPLIED", why))
+ print(" %-32s NOT APPLIED (pattern did not change anything)" % name)
+ continue
+ with open(mutant_path, "w", encoding="utf-8") as f:
+ f.write(mutant)
+ if not install(mutant_path, args.server, args.password, name):
+ results.append((name, "INSTALL_FAIL", why))
+ continue
+ failed, err = run_harness(args.server, args.password)
+ if failed is None:
+ results.append((name, "HARNESS_UNPARSED", why))
+ print(" %-32s harness output unparsed:\n%s" % (name, err or ""))
+ continue
+ caught = failed > baseline_failed
+ results.append((name, "CAUGHT" if caught else "SURVIVED", why))
+ print(" %-32s -> %s (%d failed)"
+ % (name, "CAUGHT" if caught else "SURVIVED !!", failed))
+ finally:
+ print("\nRestoring the real build from the repo...")
+ restored = install(REPO_PROC, args.server, args.password, "repo-restore")
+ if not restored:
+ print("!! FAILED to restore the real build. Reinstall manually:")
+ print(" sqlcmd -S %s -U sa -P *** -d master -i \"%s\" -b"
+ % (args.server, REPO_PROC))
+
+ print("\n==== MUTATION SUMMARY ====")
+ for (name, verdict, why) in results:
+ print(" %-32s %s" % (name, verdict))
+ print(" (%s)" % why)
+
+ survived = [n for (n, v, _w) in results if v != "CAUGHT"]
+ caught = sum(1 for (_n, v, _w) in results if v == "CAUGHT")
+ print("\n%d of %d mutations caught." % (caught, len(results)))
+ if survived:
+ print("SURVIVED / not caught: " + ", ".join(survived))
+ print("A surviving mutation is a hole in run_tests.py.")
+ sys.exit(1)
+ print("All planted bugs were caught: the harness has teeth.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sp_QueryReproBuilder/tests/run_tests.py b/sp_QueryReproBuilder/tests/run_tests.py
new file mode 100644
index 00000000..3413d7a0
--- /dev/null
+++ b/sp_QueryReproBuilder/tests/run_tests.py
@@ -0,0 +1,807 @@
+"""
+sp_QueryReproBuilder generate-and-execute test harness
+======================================================
+sp_QueryReproBuilder reads a query plan and emits a RUNNABLE reproduction script
+(the T-SQL in #repro_queries.executable_query, surfaced in the primary result set
+keyed table_name = 'results'). @query_plan_xml = bypasses Query
+Store and is the deterministic entry point this harness drives.
+
+For every case the harness:
+ 1. GENERATES - feeds an embedded ShowPlanXML through @query_plan_xml and pulls
+ the emitted repro out of the executable_query processing instruction
+ (template_generate.sql; extracted from stdout, never INSERT ... EXECUTE).
+ 2. EXECUTES - hands the repro back and runs it for real with sys.sp_executesql
+ inside BEGIN TRANSACTION ... ROLLBACK / TRY-CATCH (template_execute.sql).
+ Compiling is not enough: many bugs in a generated repro are semantic and
+ only surface when the script actually runs.
+ 3. ASSERTS - the repro built, is correct (param count/types/values and
+ statement text preserved), and RAN.
+
+Every plan is embedded as a string constant, so the suite is fully portable and
+deterministic - it depends on no captured plan cache and no specific user
+database. Plans reference sys objects (always present); the one case that needs
+a real user table uses a small fixture the harness creates in tempdb and drops.
+
+The authentic ParameterCompiledValue serializations used below (e.g. numeric as
+(12.50), money as ($99.9500), datetimeoffset as '... +05:30', guid as
+{guid'...'}) were captured from real sniffed plans on SQL Server, so the
+embedded ParameterList entries match what the procedure sees in the wild.
+
+Usage:
+ python run_tests.py [--server SQL2022] [--password L!nt0044] [--only SUBSTR] [--verbose]
+
+Exits 1 if any assertion fails.
+"""
+
+import argparse
+import atexit
+import base64
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+GEN_TEMPLATE = os.path.join(HERE, "template_generate.sql")
+EXEC_TEMPLATE = os.path.join(HERE, "template_execute.sql")
+
+# Per-case .sql files are ephemeral; keep them out of the committed tests dir.
+WORKDIR = tempfile.mkdtemp(prefix="qrb_tests_")
+atexit.register(lambda: shutil.rmtree(WORKDIR, ignore_errors=True))
+NS = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"
+FIXTURE = "tempdb.dbo.qrb_repro_fixture"
+
+# Authentic ParameterCompiledValue serializations captured from real sniffed
+# plans on SQL Server. Kept as constants so the embedded plans stay honest.
+CV = {
+ "int_5": "(5)",
+ "bigint": "(123456789)",
+ "numeric_38_2": "(12.50)",
+ "decimal_38_10": "(3.1415926535)",
+ "money": "($99.9500)",
+ "float": "(2.2500000000000000e+000)",
+ "real": "(1.5000000000000000e+000)",
+ "bit": "(1)",
+ "datetime": "'2020-01-15 10:20:30.123'",
+ "datetime2_7": "'2020-01-15 10:20:30.1234567'",
+ "datetimeoffset": "'2020-01-15 10:20:30.1234567 +05:30'",
+ "date": "'2020-01-15'",
+ "time_7": "'10:20:30.1234567'",
+ "guid": "{guid'6F9619FF-8B86-D011-B42D-00C04FC964FF'}",
+ "varchar_8000": "'Autobiographer'",
+ "nvarchar_40": "N'Community'",
+ "varbinary_8": "0x0102030405060708",
+}
+
+# Warning text the procedure emits when a parameter is declared in the query
+# text but absent from the plan ParameterList (the documented '?' fill-in path).
+QMARK_WARNING = "were not found in the plan ParameterList"
+
+
+# ------------------------------------------------------------------ plan builder
+
+def xesc(s):
+ """Escape a string for an XML double-quoted attribute value.
+
+ CR/LF/TAB become numeric character references: a literal CR/TAB in an
+ attribute is normalized by the XML parser, so to make the procedure actually
+ see a carriage return in a compiled value (as real plans store it, via
+
) we emit
etc.
+ """
+ return (s.replace("&", "&").replace("<", "<")
+ .replace(">", ">").replace('"', """)
+ .replace("\r", "
").replace("\n", "
").replace("\t", " "))
+
+
+def make_plan(stmt_text, params=None, body_extra="", statements_extra="", body_pre=""):
+ """Build a minimal well-formed ShowPlanXML the procedure can parse.
+
+ stmt_text: StatementText. For a parameterized repro it must begin with the
+ '(@name type, ...)' prefix exactly as SQL Server stores it; the procedure
+ strips that prefix and rebuilds the declaration list from the
+ ParameterList (not from the text).
+ params: list of (column, datatype, compiled_value) -> a ParameterList. Pass
+ None to omit the ParameterList entirely (drives the '?' fill-in path when
+ the text still declares parameters).
+ body_pre: raw XML injected BEFORE the query's ParameterList (e.g. a decoy
+ ParameterList, to probe the first-ParameterList-wins extraction).
+ """
+ plist = ""
+ if params is not None:
+ rows = "".join(
+ ''.format(xesc(c), xesc(dt), xesc(cv))
+ for (c, dt, cv) in params
+ )
+ plist = "{}".format(rows)
+ return (
+ ''
+ ''
+ ''
+ ''
+ ''
+ '{bp}{pl}{be}'
+ ''
+ ''
+ '{se}'
+ ''
+ ''
+ ).format(ns=NS, st=xesc(stmt_text), pl=plist, be=body_extra, se=statements_extra, bp=body_pre)
+
+
+# An internal-operator ParameterList (nested ColumnReference, no
+# ParameterDataType), like the ones XML-reader / UDF operators emit. It
+# serializes as too, which is what makes it a decoy for the
+# procedure's substring-based first-ParameterList extraction.
+DECOY_PLIST = (''
+ ''
+ '')
+
+
+# ------------------------------------------------------------------ sqlcmd plumbing
+
+def _write_sql(path, sql):
+ """Write a case file as UTF-8 with BOM so go-sqlcmd reads any N'...' unicode
+ correctly."""
+ with open(path, "w", encoding="utf-8-sig") as f:
+ f.write(sql)
+
+
+def _run_sql_file(server, password, path):
+ cmd = [
+ "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ "-d", "master", "-i", path, "-y", "0", "-h", "-1",
+ ]
+ # Capture bytes and decode as UTF-8: go-sqlcmd emits UTF-8 on stdout, so a
+ # unicode repro survives the round trip (text=True would use the console
+ # code page on Windows and mangle it).
+ r = subprocess.run(cmd, capture_output=True, timeout=180)
+ out = (r.stdout or b"").decode("utf-8", errors="replace")
+ err = (r.stderr or b"").decode("utf-8", errors="replace")
+ return out + "\n" + err
+
+
+def find_sql_errors(text):
+ """Return SQL errors of severity 16 or higher. go-sqlcmd reports errors on
+ stdout, so callers pass the combined stream. Matching severity numerically
+ catches Level 16 through 19 rather than only the literal 'Level 16'."""
+ if not text:
+ return []
+ return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text)
+
+
+def generate(server, password, plan_text):
+ """Run one plan through the procedure and pull the emitted repro out of the
+ executable_query processing instruction on stdout."""
+ with open(GEN_TEMPLATE, "r", encoding="utf-8") as f:
+ tmpl = f.read()
+ sql = tmpl.replace("@@PLAN@@", plan_text.replace("'", "''"))
+ path = os.path.join(WORKDIR, "_gen_case.sql")
+ _write_sql(path, sql)
+ out = _run_sql_file(server, password, path)
+
+ res = {"raw": out, "repro": None, "proc_error": None, "no_repro": False,
+ "sql_errors": find_sql_errors(out)}
+
+ m = re.search(r"PROC_ERROR:\s*(.+)", out)
+ if m:
+ res["proc_error"] = m.group(1).strip()
+
+ # The repro renders as a processing instruction: . Its
+ # content is emitted raw (entities decoded), so it is extracted verbatim.
+ m = re.search(r"<\?_(.*?)\?>", out, re.DOTALL)
+ if m:
+ res["repro"] = m.group(1).strip("\r\n").lstrip()
+
+ if res["repro"] is None:
+ res["no_repro"] = True
+ return res
+
+
+def _b64_assignments(repro):
+ b = base64.b64encode(repro.encode("utf-16-le")).decode("ascii")
+ chunks = [b[i:i + 7000] for i in range(0, len(b), 7000)]
+ return "\n".join("SET @b64 = @b64 + '%s';" % c for c in chunks)
+
+
+RUNNER_PLAIN = """\
+BEGIN TRANSACTION;
+BEGIN TRY
+ EXECUTE sys.sp_executesql @repro;
+ PRINT 'EXEC_RESULT: PASS';
+END TRY
+BEGIN CATCH
+ PRINT 'EXEC_RESULT: FAIL Msg ' + CONVERT(varchar(20), ERROR_NUMBER()) +
+ ' Lvl ' + CONVERT(varchar(20), ERROR_SEVERITY()) +
+ ' : ' + LEFT(ERROR_MESSAGE(), 300);
+END CATCH;
+IF XACT_STATE() <> 0
+ ROLLBACK TRANSACTION;
+"""
+
+RUNNER_ECHO = """\
+CREATE TABLE #echo (col_a int NULL, col_s nvarchar(100) NULL, col_b int NULL);
+BEGIN TRANSACTION;
+BEGIN TRY
+ INSERT #echo
+ EXECUTE sys.sp_executesql @repro;
+ PRINT 'EXEC_RESULT: PASS';
+ SELECT echo_line =
+ 'ECHO: a=[' + ISNULL(CONVERT(varchar(20), e.col_a), '') +
+ '] s=[' + ISNULL(e.col_s, '') +
+ '] b=[' + ISNULL(CONVERT(varchar(20), e.col_b), '') + ']'
+ FROM #echo AS e;
+END TRY
+BEGIN CATCH
+ PRINT 'EXEC_RESULT: FAIL Msg ' + CONVERT(varchar(20), ERROR_NUMBER()) +
+ ' Lvl ' + CONVERT(varchar(20), ERROR_SEVERITY()) +
+ ' : ' + LEFT(ERROR_MESSAGE(), 300);
+END CATCH;
+IF XACT_STATE() <> 0
+ ROLLBACK TRANSACTION;
+"""
+
+
+def execute(server, password, repro, echo=False):
+ """Execute a repro for real, inside a rolled-back transaction. Returns the
+ EXEC_RESULT verdict and (for echo cases) the actually-bound values."""
+ with open(EXEC_TEMPLATE, "r", encoding="utf-8") as f:
+ tmpl = f.read()
+ sql = (tmpl.replace("@@B64_ASSIGNMENTS@@", _b64_assignments(repro))
+ .replace("@@RUNNER@@", RUNNER_ECHO if echo else RUNNER_PLAIN))
+ path = os.path.join(WORKDIR, "_exec_case.sql")
+ _write_sql(path, sql)
+ out = _run_sql_file(server, password, path)
+
+ res = {"raw": out, "exec_result": None, "echo": None,
+ "sql_errors": find_sql_errors(out)}
+ m = re.search(r"EXEC_RESULT:\s*(PASS|FAIL[^\n]*)", out)
+ if m:
+ res["exec_result"] = "PASS" if m.group(1) == "PASS" else m.group(1).strip()
+ m = re.search(r"ECHO: a=\[(.*?)\] s=\[(.*?)\] b=\[(.*?)\]", out)
+ if m:
+ res["echo"] = {"a": m.group(1), "s": m.group(2), "b": m.group(3)}
+ return res
+
+
+# ------------------------------------------------------------------ repro parsing
+
+def sql_literal_segments(text):
+ """Yield (start, end, content) for each N'...'-quoted literal in order, with
+ '' collapsed back to a single quote in content."""
+ segs = []
+ i = 0
+ n = len(text)
+ while i < n:
+ q = text.find("'", i)
+ if q < 0:
+ break
+ j = q + 1
+ buf = []
+ while j < n:
+ if text[j] == "'":
+ if j + 1 < n and text[j + 1] == "'":
+ buf.append("'")
+ j += 2
+ continue
+ break
+ buf.append(text[j])
+ j += 1
+ segs.append((q, j, "".join(buf)))
+ i = j + 1
+ return segs
+
+
+def split_top_values(s):
+ """Split an sp_executesql trailing value list on top-level commas (commas
+ that are not inside a string literal)."""
+ vals = []
+ cur = []
+ i = 0
+ n = len(s)
+ in_str = False
+ while i < n:
+ ch = s[i]
+ if ch == "'":
+ in_str = not in_str
+ cur.append(ch)
+ i += 1
+ continue
+ if not in_str and ch == ",":
+ vals.append("".join(cur).strip())
+ cur = []
+ i += 1
+ while i < n and s[i] == " ":
+ i += 1
+ continue
+ cur.append(ch)
+ i += 1
+ last = "".join(cur).strip()
+ if last:
+ vals.append(last)
+ return vals
+
+
+# ------------------------------------------------------------------ test battery
+
+def build_cases():
+ cases = []
+
+ def add(name, plan, checks, echo=False, note=""):
+ cases.append({"name": name, "plan": plan, "checks": checks,
+ "echo": echo, "note": note})
+
+ # ---------- Parameterized, scaled/precision and edge types --------------
+ # Each embeds the authentic ParameterCompiledValue for its type and asserts
+ # the declared type round-trips into the sp_executesql declaration list and
+ # the repro RUNS. This is the core of the required coverage: numeric(38,2),
+ # decimal, varchar(8000), nvarchar(max), datetime2, datetimeoffset, plus a
+ # broad type sweep.
+ typed = [
+ ("p_int", "@p", "int", CV["int_5"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id = @p", ["5"]),
+ ("p_bigint", "@p", "bigint", CV["bigint"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.object_id = @p", ["123456789"]),
+ ("p_numeric_38_2", "@amt", "numeric(38,2)", CV["numeric_38_2"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @amt", ["12.50"]),
+ ("p_decimal_38_10", "@d", "decimal(38,10)", CV["decimal_38_10"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @d", ["3.1415926535"]),
+ ("p_money", "@m", "money", CV["money"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @m", ["$99.9500"]),
+ ("p_float", "@f", "float", CV["float"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @f", None),
+ ("p_real", "@r", "real", CV["real"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @r", None),
+ ("p_bit", "@b", "bit", CV["bit"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.is_nullable = @b", None),
+ ("p_datetime", "@d", "datetime", CV["datetime"],
+ "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.modify_date > @d", None),
+ ("p_datetime2_7", "@d", "datetime2(7)", CV["datetime2_7"],
+ "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.modify_date > @d", None),
+ ("p_datetimeoffset", "@d", "datetimeoffset(7)", CV["datetimeoffset"],
+ "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.modify_date > @d", None),
+ ("p_date", "@d", "date", CV["date"],
+ "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE CONVERT(date, o.modify_date) > @d", None),
+ ("p_time_7", "@t", "time(7)", CV["time_7"],
+ "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE CONVERT(time(7), o.modify_date) > @t", None),
+ ("p_uniqueidentifier", "@g", "uniqueidentifier", CV["guid"],
+ "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.object_id > 0 AND @g IS NOT NULL", None),
+ ("p_varchar_8000", "@nm", "varchar(8000)", CV["varchar_8000"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = @nm", ["'Autobiographer'"]),
+ ("p_nvarchar_40", "@nm", "nvarchar(40)", CV["nvarchar_40"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = @nm", ["N'Community'"]),
+ ("p_nvarchar_max", "@b", "nvarchar(max)", "N'hello'",
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE CONVERT(nvarchar(max), ac.name) = @b", ["N'hello'"]),
+ ("p_varbinary_8", "@vb", "varbinary(8)", CV["varbinary_8"],
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE @vb IS NOT NULL AND ac.column_id > 0", None),
+ ]
+ for (name, pn, dt, cv, body, present) in typed:
+ stmt = "(%s %s)%s" % (pn, dt, body)
+ add("type:%s" % name,
+ make_plan(stmt, params=[(pn, dt, cv)]),
+ {"must_exec": True, "expect_params": [(pn, dt)],
+ "values_present": present})
+
+ # ---------- No-parameter plans ----------------------------------------
+ add("noparam:sys",
+ make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_objects AS o WHERE o.object_id > 0"),
+ {"must_exec": True})
+
+ # ---------- Long statement text: > 4000 and > 8000 --------------------
+ pred_5k = " OR ".join("o.object_id = %d" % k for k in range(0, 300)) # ~ 5k chars
+ add("long:stmt_gt4000",
+ make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_objects AS o WHERE " + pred_5k),
+ {"must_exec": True, "min_repro_len": 4000})
+
+ pred_9k = " OR ".join("o.object_id = %d" % k for k in range(0, 900)) # ~ 16k chars
+ add("long:stmt_gt8000",
+ make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_objects AS o WHERE " + pred_9k),
+ {"must_exec": True, "min_repro_len": 8000})
+
+ long_in = ",".join("@p%d" % k for k in range(1, 400))
+ long_decls = ",".join("@p%d int" % k for k in range(1, 400))
+ add("long:param_stmt_gt8000",
+ make_plan("(%s)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id IN (%s)"
+ % (long_decls, long_in),
+ params=[("@p%d" % k, "int", "(%d)" % k) for k in range(1, 400)]),
+ {"must_exec": True, "min_repro_len": 8000})
+
+ # ---------- Entity characters (& < > ') in statement text -------------
+ add("entity:literal_lt_gt_amp",
+ make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = N'a&c'"),
+ {"must_exec": True, "stmt_contains": "a&c"})
+
+ add("entity:gt_lt_in_query",
+ make_plan("SELECT x = (SELECT 1 WHERE 3 < 5 AND 5 > 3)"),
+ {"must_exec": True})
+
+ # Entity characters in a parameter's compiled value (authentic plans double
+ # the quote inside the attribute: ParameterCompiledValue="N'O''Brien & '").
+ add("entity:param_value_quote_entity",
+ make_plan("(@n nvarchar(40))SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = @n",
+ params=[("@n", "nvarchar(40)", "N'O''Brien & '")]),
+ {"must_exec": True, "values_present": ["O''Brien"]})
+
+ # ---------- Apostrophes -------------------------------------------------
+ # Apostrophe inside a parameterized statement: must be re-doubled when the
+ # statement is wrapped in the outer N'...' for sp_executesql.
+ add("apostrophe:in_param_stmt",
+ make_plan("(@x int)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac "
+ "WHERE ac.name = N'O''Brien' AND ac.column_id = @x",
+ params=[("@x", "int", "(1)")]),
+ {"must_exec": True, "stmt_contains": "O'"})
+
+ # Apostrophe inside a non-parameterized statement (emitted as raw text).
+ add("apostrophe:in_raw_stmt",
+ make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = N'D''Angelo'"),
+ {"must_exec": True, "stmt_contains": "D'"})
+
+ # ---------- Unicode / N-literal ---------------------------------------
+ # Literal kept as \u escapes so this file stays ASCII; at runtime the string
+ # carries real U+00E9 (e-acute) and U+4E2D U+6587 (Chinese).
+ add("unicode:n_literal",
+ make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac "
+ "WHERE ac.name = N'" + chr(0x00e9) + chr(0x4e2d) + chr(0x6587) + "'"),
+ {"must_exec": True, "stmt_contains": chr(0x4e2d) + chr(0x6587)})
+
+ # ---------- Alignment / multi-parameter --------------------------------
+ add("align:autoparam_12",
+ make_plan("(@1 int,@2 int,@3 int,@4 int,@5 int,@6 int,@7 int,@8 int,@9 int,@10 int,@11 int,@12 int)"
+ "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id IN "
+ "(@1,@2,@3,@4,@5,@6,@7,@8,@9,@10,@11,@12)",
+ params=[("@%d" % k, "int", "(%d)" % k) for k in range(1, 13)]),
+ {"must_exec": True, "align_check": True,
+ "expect_params": [("@%d" % k, "int") for k in range(1, 13)]})
+
+ # Declared order (@z, @a) differs from ParameterList order (@a, @z) and the
+ # types differ; both declaration and value lists must sort by name so value i
+ # binds to declaration i.
+ add("align:name_order_mismatch",
+ make_plan("(@z int, @a nvarchar(20))SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac "
+ "WHERE ac.column_id = @z AND ac.name = @a",
+ params=[("@a", "nvarchar(20)", "N'hi'"), ("@z", "int", "(5)")]),
+ {"must_exec": True, "align_check": True,
+ "expect_params": [("@a", "nvarchar(20)"), ("@z", "int")]})
+
+ # ---------- Control characters in a parameter value --------------------
+ # A sniffed string value with an embedded CR/LF is realistic (addresses,
+ # JSON, SQL). The value list is built with FOR XML ... value('./text()[1]');
+ # the marker after the control char must survive (not be truncated).
+ add("control:cr_in_value",
+ make_plan("(@s nvarchar(50))SELECT col_a = 1, col_s = @s, col_b = 2",
+ params=[("@s", "nvarchar(50)", "N'AAA" + chr(13) + "ZZZ'")]),
+ {"must_exec": True, "repro_contains": "ZZZ"})
+
+ add("control:lf_in_value",
+ make_plan("(@s nvarchar(50))SELECT col_a = 1, col_s = @s, col_b = 2",
+ params=[("@s", "nvarchar(50)", "N'AAA" + chr(10) + "ZZZ'")]),
+ {"must_exec": True, "repro_contains": "ZZZ"})
+
+ # ---------- Embedded constant -----------------------------------------
+ add("const:embedded_42",
+ make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id = 42",
+ body_extra=''
+ ''),
+ {"must_exec": True})
+
+ # ---------- Non-SELECT (UPDATE) against a real user table -------------
+ # Needs a real table (cannot UPDATE a sys view). The harness creates
+ # tempdb.dbo.qrb_repro_fixture at startup and drops it at the end; the repro
+ # runs inside the rolled-back transaction so the write is undone.
+ add("update:param",
+ make_plan("(@r int)UPDATE f SET f.val = f.val FROM " + FIXTURE + " AS f WHERE f.id = @r",
+ params=[("@r", "int", "(1)")]),
+ {"must_exec": True})
+
+ # ---------- The documented '?' fill-in path (warned) ------------------
+ # A parameter is declared in the query text but absent from the plan's
+ # ParameterList. The procedure sets its value to ? and WARNS. This is
+ # deliberate: the repro fails LOUD (Msg 102 near '?') rather than silently
+ # running with a wrong value. Assert the warning fires, the placeholder is
+ # present, and execution does not silently pass.
+ add("qmark:no_paramlist_declared",
+ make_plan("(@p1 int)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id = @p1",
+ params=None),
+ {"expect_qmark": True})
+
+ # Same, with a scaled type in the text: the declared numeric(10,2) must not
+ # be split on its internal comma while being carried into the declaration.
+ add("qmark:scaled_fillin",
+ make_plan("(@a numeric(10,2), @b int)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac "
+ "WHERE ac.column_id = @b", params=None),
+ {"expect_qmark": True, "decls_contain": ["numeric(10,2)"]})
+
+ # ---------- First-ParameterList-wins hazard ----------------------------
+ # E6-style control: real ParameterList first, decoy second -> binds 555.
+ add("decoy:real_first_control",
+ make_plan("(@id int)SELECT col_a = @id, col_s = N'q', col_b = @id",
+ params=[("@id", "int", "(555)")],
+ body_extra=DECOY_PLIST),
+ {"echo_expect": {"a": "555", "s": "q", "b": "555"}}, echo=True)
+
+ # E5-style probe: a decoy ParameterList (as XML-reader / UDF operators emit)
+ # precedes the query's own. The procedure substring-extracts the FIRST
+ # , so the real @id is lost -> the fill-in path warns and
+ # emits ? -> the repro fails LOUD. This documents that the hazard degrades
+ # safely (loud failure with a warning), not into a silent wrong result.
+ add("decoy:decoy_first_degrades_loud",
+ make_plan("(@id int)SELECT col_a = @id, col_s = N'q', col_b = @id",
+ params=[("@id", "int", "(555)")],
+ body_pre=DECOY_PLIST),
+ {"expect_qmark": True,
+ "note": "decoy ParameterList precedes query's -> real @id skipped, ? fill-in"})
+
+ # ---------- Echo cases: assert the ACTUAL bound values -----------------
+ # These execute the repro and read back what was really bound, catching
+ # silent value/parameter reordering that would still run.
+ add("echo:two_int",
+ make_plan("(@a int, @b int)SELECT col_a = @a, col_s = N'x', col_b = @b",
+ params=[("@a", "int", "(999)"), ("@b", "int", "(111)")]),
+ {"echo_expect": {"a": "999", "s": "x", "b": "111"}}, echo=True)
+
+ add("echo:mixed_types",
+ make_plan("(@a int, @z int, @m nvarchar(20))SELECT col_a = @a, col_s = @m, col_b = @z",
+ params=[("@a", "int", "(777)"), ("@z", "int", "(222)"),
+ ("@m", "nvarchar(20)", "N'mid'")]),
+ {"echo_expect": {"a": "777", "s": "mid", "b": "222"}}, echo=True)
+
+ add("echo:quote_entity_value",
+ make_plan("(@a int, @s nvarchar(50), @b int)SELECT col_a = @a, col_s = @s, col_b = @b",
+ params=[("@a", "int", "(1)"),
+ ("@s", "nvarchar(50)", "N'O''Brien & '"),
+ ("@b", "int", "(2)")]),
+ {"echo_expect": {"a": "1", "s": "O'Brien & ", "b": "2"}}, echo=True)
+
+ add("echo:numeric_value_paren_strip",
+ make_plan("(@a int, @b int)SELECT col_a = @a, col_s = N'n', col_b = @b",
+ params=[("@a", "int", "(-5)"), ("@b", "int", "(2147483647)")]),
+ {"echo_expect": {"a": "-5", "s": "n", "b": "2147483647"}}, echo=True)
+
+ # ---------- Negative: no repro should be built -------------------------
+ add("negative:no_stmtsimple",
+ ''
+ '' % NS,
+ {"expect_no_repro": True})
+
+ add("negative:not_showplan",
+ 'hello',
+ {"expect_no_repro": True})
+
+ return cases
+
+
+# ------------------------------------------------------------------ assertions
+
+def check_case(case, gen, ex):
+ """Return list of (ok, label, detail)."""
+ out = []
+ checks = case["checks"]
+
+ def ck(ok, label, detail=""):
+ out.append((bool(ok), label, detail))
+
+ repro = gen["repro"] or ""
+
+ # ----- negative cases: the procedure must refuse, and say why ----------
+ if checks.get("expect_no_repro"):
+ ck(gen["repro"] is None, "no repro emitted", "repro present")
+ ck("has no StmtSimple" in gen["raw"],
+ "StmtSimple diagnostic emitted", "diagnostic message absent")
+ ck(not gen["sql_errors"], "no severe error on no-repro", str(gen["sql_errors"]))
+ return out
+
+ # ----- '?' fill-in path: warned, builds, fails loud, never silent ------
+ if checks.get("expect_qmark"):
+ ck(gen["repro"] is not None and gen["proc_error"] is None,
+ "repro built", "proc_error=%s" % gen["proc_error"])
+ ck("?" in repro, "contains ? placeholder", "no ? in repro")
+ ck(QMARK_WARNING in repro, "missing-parameter warning fires",
+ "warning text absent from repro")
+ for v in checks.get("decls_contain", []):
+ ck(v in repro, "decls contain: %s" % v, "not found in repro")
+ # documented behavior: it must NOT silently pass with a wrong value.
+ ck(ex is not None and ex["exec_result"] != "PASS",
+ "repro fails loud (not a silent wrong result)",
+ "exec_result=%s" % (ex["exec_result"] if ex else None))
+ return out
+
+ # ----- echo cases: assert the actually-bound values --------------------
+ if "echo_expect" in checks:
+ exp = checks["echo_expect"]
+ ck(gen["repro"] is not None and gen["proc_error"] is None,
+ "echo repro built", "proc_error=%s" % gen["proc_error"])
+ ck(ex["exec_result"] == "PASS", "echo repro executes",
+ "exec_result=%s" % ex["exec_result"])
+ got = ex["echo"]
+ ck(got is not None, "echo captured",
+ "no ECHO line (exec_result=%s)" % ex["exec_result"])
+ if got:
+ for k in ("a", "s", "b"):
+ ck(got[k] == exp[k], "echo %s == %r" % (k, exp[k]), "got %r" % got[k])
+ return out
+
+ # ----- normal cases ----------------------------------------------------
+ ck(gen["repro"] is not None and gen["proc_error"] is None,
+ "repro built without proc error",
+ "proc_error=%s no_repro=%s" % (gen["proc_error"], gen["no_repro"]))
+
+ if checks.get("min_repro_len"):
+ ck(len(repro) >= checks["min_repro_len"],
+ "repro length >= %d" % checks["min_repro_len"], "len=%d" % len(repro))
+
+ if checks.get("stmt_contains"):
+ ck(checks["stmt_contains"] in repro, "statement text preserved",
+ "missing %r" % checks["stmt_contains"])
+
+ if checks.get("repro_contains"):
+ ck(checks["repro_contains"] in repro, "value not truncated (marker present)",
+ "marker %r absent" % checks["repro_contains"])
+
+ for v in (checks.get("values_present") or []):
+ ck(v in repro, "value present: %s" % v, "not found in repro")
+
+ # parameter declaration count + types
+ if checks.get("expect_params") is not None:
+ expect = checks["expect_params"]
+ body = repro[repro.find("EXECUTE sys.sp_executesql"):] \
+ if "EXECUTE sys.sp_executesql" in repro else ""
+ segs = sql_literal_segments(body)
+ if len(segs) >= 2:
+ decls = segs[1][2]
+ got_names = re.findall(r"@\w+", decls)
+ ck(len(got_names) == len(expect), "param count == %d" % len(expect),
+ "got %d (%s)" % (len(got_names), decls[:160]))
+ for (nm, dt) in expect:
+ pat = re.escape(nm) + r"\s+" + re.escape(dt) + r"(?:\s*,|\s*$)"
+ ck(re.search(pat, decls) is not None,
+ "decl %s %s present" % (nm, dt), "decls=%s" % decls[:200])
+ else:
+ ck(False, "sp_executesql decls found", "segments=%d" % len(segs))
+
+ # declaration list and value list must be positionally consistent
+ if checks.get("align_check"):
+ body = repro[repro.find("EXECUTE sys.sp_executesql"):] \
+ if "EXECUTE sys.sp_executesql" in repro else ""
+ segs = sql_literal_segments(body)
+ if len(segs) >= 2:
+ decls = segs[1][2]
+ parts = [p.strip() for p in re.split(r",\s*(?=@)", decls) if p.strip()]
+ names = []
+ for p in parts:
+ mm = re.match(r"(@\w+)\s+(.+)", p)
+ if mm:
+ names.append((mm.group(1), mm.group(2).strip()))
+ after = body[segs[1][1] + 1:].split(";", 1)[0]
+ after = after.lstrip().lstrip(",").lstrip()
+ vals = split_top_values(after)
+ ck(len(names) == len(vals), "decl count == value count",
+ "decls=%d vals=%d :: %s || %s" % (len(names), len(vals), decls[:120], after[:120]))
+ for ((nm, dt), val) in zip(names, vals):
+ dtl = dt.strip().lower()
+ v = val.strip()
+ if dtl.startswith(("numeric", "decimal", "int", "bigint", "smallint",
+ "tinyint", "float", "real", "money")):
+ ck(not v.startswith("N'") and not v.startswith("'"),
+ "numeric param %s not bound to a string value" % nm, "val=%r" % v)
+ if dtl.startswith(("nvarchar", "varchar", "nchar", "char", "sysname")):
+ ck(v.startswith("N'") or v.startswith("'") or v in ("NULL", "?"),
+ "string param %s bound to a string value" % nm, "val=%r" % v)
+ else:
+ ck(False, "align: sp_executesql decls found", "segments=%d" % len(segs))
+
+ # the point of the whole thing: it must RUN, not just parse
+ if checks.get("must_exec"):
+ ck(ex is not None and ex["exec_result"] == "PASS",
+ "repro EXECUTES (runs, not just parses)",
+ "exec_result=%s" % (ex["exec_result"] if ex else None))
+ ck(not (ex or {}).get("sql_errors"),
+ "no severe SQL error during execution", str((ex or {}).get("sql_errors")))
+
+ return out
+
+
+# ------------------------------------------------------------------ fixture
+
+FIXTURE_SETUP = """\
+SET NOCOUNT ON;
+IF OBJECT_ID('tempdb.dbo.qrb_repro_fixture') IS NOT NULL
+ DROP TABLE tempdb.dbo.qrb_repro_fixture;
+CREATE TABLE tempdb.dbo.qrb_repro_fixture
+(
+ id integer NOT NULL,
+ val integer NOT NULL,
+ name nvarchar(50) NULL
+);
+INSERT tempdb.dbo.qrb_repro_fixture (id, val, name)
+VALUES (1, 10, N'a'), (2, 20, N'b'), (3, 30, N'c');
+"""
+
+FIXTURE_TEARDOWN = """\
+IF OBJECT_ID('tempdb.dbo.qrb_repro_fixture') IS NOT NULL
+ DROP TABLE tempdb.dbo.qrb_repro_fixture;
+"""
+
+
+def _run_inline(server, password, sql, tag):
+ path = os.path.join(WORKDIR, "_%s.sql" % tag)
+ _write_sql(path, sql)
+ return _run_sql_file(server, password, path)
+
+
+# ------------------------------------------------------------------ main
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--server", default="SQL2022")
+ ap.add_argument("--password", default="L!nt0044")
+ ap.add_argument("--only", default=None, help="substring filter on case name")
+ ap.add_argument("--verbose", action="store_true")
+ args = ap.parse_args()
+
+ # A failure detail can contain non-ASCII (e.g. the unicode case); make sure
+ # printing it never crashes on a Windows console code page.
+ try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ except Exception:
+ pass
+
+ print("Running sp_QueryReproBuilder generate-and-execute tests against %s..." % args.server)
+ print()
+
+ setup_out = _run_inline(args.server, args.password, FIXTURE_SETUP, "fixture_setup")
+ setup_err = find_sql_errors(setup_out)
+ if setup_err:
+ print("ERROR: could not create the tempdb fixture:")
+ for e in setup_err:
+ print(" " + e)
+ sys.exit(1)
+
+ cases = build_cases()
+ if args.only:
+ cases = [c for c in cases if args.only in c["name"]]
+
+ results = []
+ try:
+ for case in cases:
+ gen = generate(args.server, args.password, case["plan"])
+ ex = None
+ need_exec = ("echo_expect" in case["checks"]
+ or case["checks"].get("must_exec")
+ or case["checks"].get("expect_qmark"))
+ if gen["repro"] is not None and need_exec:
+ ex = execute(args.server, args.password, gen["repro"], echo=case["echo"])
+ checks = check_case(case, gen, ex)
+ case_failed = any(not ok for (ok, _l, _d) in checks)
+ for (ok, label, detail) in checks:
+ results.append((case["name"], ok, label, detail))
+ if case_failed and not args.verbose:
+ exr = ex["exec_result"] if ex else None
+ print("FAIL %s" % case["name"])
+ for (ok, label, detail) in checks:
+ if not ok:
+ print(" - %s :: %s" % (label, detail))
+ print(" exec_result=%s proc_error=%s" % (exr, gen["proc_error"]))
+ if gen["repro"]:
+ print(" repro[:300]=%s" % gen["repro"][:300].replace("\n", "\\n"))
+ elif not case_failed:
+ print("PASS %s" % case["name"])
+ if args.verbose:
+ for (ok, label, detail) in checks:
+ print(" - %s" % label)
+ finally:
+ _run_inline(args.server, args.password, FIXTURE_TEARDOWN, "fixture_teardown")
+
+ passed = sum(1 for (_n, ok, _l, _d) in results if ok)
+ failed = sum(1 for (_n, ok, _l, _d) in results if not ok)
+
+ print()
+ print("Results: %d passed, %d failed, %d total" % (passed, failed, len(results)))
+
+ if failed:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/sp_QueryReproBuilder/tests/template_execute.sql b/sp_QueryReproBuilder/tests/template_execute.sql
new file mode 100644
index 00000000..ca0a4a93
--- /dev/null
+++ b/sp_QueryReproBuilder/tests/template_execute.sql
@@ -0,0 +1,40 @@
+/*
+Execution half of the sp_QueryReproBuilder generate-and-execute harness.
+
+This is the check that earns the harness its keep. A repro that reads correctly
+but will not run is the characteristic failure mode of a generator like this, and
+only executing the generated script catches it. SET PARSEONLY ON would sail past
+a semantic error; running the repro for real does not.
+
+run_tests.py hands the emitted repro back in as base64 of its utf-16-le bytes
+(assigned in @@B64_ASSIGNMENTS@@), so there is no quote-escaping to get wrong and
+no 8000-character string-literal limit to trip over on long repros. The repro
+lands in a genuine nvarchar(max) variable and is executed with
+sys.sp_executesql inside BEGIN TRANSACTION ... ROLLBACK and TRY/CATCH:
+
+ - the transaction rolls back any writes the repro performs (e.g. an UPDATE),
+ - sp_executesql defers the repro's compile, so a compile or semantic error in
+ the generated SQL is catchable here instead of aborting the whole batch, and
+ - EXEC_RESULT reports PASS only when the repro actually ran.
+
+@@RUNNER@@ is replaced with either a plain execute or, for echo cases, an
+INSERT ... EXECUTE into a known 3-column #echo table so the actually-bound
+parameter values can be read back and asserted.
+*/
+SET NOCOUNT ON;
+SET XACT_ABORT OFF;
+
+DECLARE
+ @b64 varchar(max) = CONVERT(varchar(max), '');
+
+@@B64_ASSIGNMENTS@@
+
+DECLARE
+ @repro nvarchar(max) =
+ CONVERT
+ (
+ nvarchar(max),
+ CAST(N'' AS xml).value('xs:base64Binary(sql:variable("@b64"))', 'varbinary(max)')
+ );
+
+@@RUNNER@@
diff --git a/sp_QueryReproBuilder/tests/template_generate.sql b/sp_QueryReproBuilder/tests/template_generate.sql
new file mode 100644
index 00000000..a0cb9ed7
--- /dev/null
+++ b/sp_QueryReproBuilder/tests/template_generate.sql
@@ -0,0 +1,31 @@
+/*
+Generation half of the sp_QueryReproBuilder generate-and-execute harness.
+
+run_tests.py substitutes @@PLAN@@ with a ShowPlanXML document (single-quotes
+already doubled) and runs this file. The procedure is driven in
+@query_plan_xml mode, which bypasses Query Store and is the deterministic entry
+point. Its primary result set (table_name = 'results') prints to stdout;
+run_tests.py extracts the emitted repro from the executable_query processing
+instruction that renders as .
+
+This is deliberately NOT an INSERT ... EXECUTE capture. sp_QueryReproBuilder can
+return different result-set shapes (the wide 'results' set when a repro is built
+versus the single-column '#repro_queries is empty' diagnostic when nothing
+lands), and a fixed-shape INSERT ... EXECUTE target cannot absorb both. Driving
+it through stdout keeps the harness correct no matter which shape comes back.
+*/
+SET NOCOUNT ON;
+SET XACT_ABORT OFF;
+
+DECLARE
+ @plan xml = CONVERT(xml, N'@@PLAN@@');
+
+BEGIN TRY
+ EXECUTE dbo.sp_QueryReproBuilder
+ @query_plan_xml = @plan;
+END TRY
+BEGIN CATCH
+ PRINT 'PROC_ERROR: Msg ' + CONVERT(varchar(20), ERROR_NUMBER()) +
+ ' Lvl ' + CONVERT(varchar(20), ERROR_SEVERITY()) +
+ ' : ' + ERROR_MESSAGE();
+END CATCH;
From 04f7dab70614a24d1b80a5ec87bc9ef526b0edc2 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 16:45:00 -0400
Subject: [PATCH 23/60] sp_QueryReproBuilder: shred each plan's parameters once
(many-plan perf)
The parameter-extraction step read cr.c.value(N'@ParameterCompiledValue') four
times in a single INSERT - inside the LIKE tests, the SUBSTRINGs, and the LENs of
the value-cleanup CASE - plus @Column and @ParameterDataType, for nine .value()
calls on the same XML node. Against untyped plan XML the optimizer builds one XML
Reader table-valued function per call; the actual plan showed ten of them stacked
in nested-loop joins, each re-parsing the plan from scratch. On many-plan Query
Store workloads this was the procedure's single largest cost.
Shred each ColumnReference's three attributes once into a #parameter_shred temp
table, then apply the existing paren-strip / guid-strip cleanup in a second pass
over those plain-string columns. It is a straight common-subexpression
elimination: the CASE logic and the LTRIM/RTRIM are unchanged, just computed once
on materialized strings instead of re-read from the XML.
Measured on sql2022 against a 497-plan Query Store database (Query Store frozen
READ_ONLY so the plan set is stable across runs): 25,687 ms -> 1,996 ms, roughly
13x, with byte-identical output. The deep single-plan case (@query_plan_xml with
plans up to ~4.7 MB) was measured separately and is already sub-linear - no
change needed there. The identical repeated-.value() pattern in the adjacent
"fill in missing parameters" step is left alone: it operates on a tiny
query-text fragment with rarely any rows and is not a measured bottleneck.
Verified on 2016/2017/2019/2022/2025: compiles clean; the tests/run_tests.py
generate-and-execute harness stays 237/237 (identical repros); full output
byte-identical to the prior build across all 497 plans.
Co-Authored-By: Claude Opus 4.8
---
sp_QueryReproBuilder/sp_QueryReproBuilder.sql | 108 +++++++++++++-----
1 file changed, 78 insertions(+), 30 deletions(-)
diff --git a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
index 12776e15..5a77a50a 100644
--- a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
+++ b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
@@ -1182,6 +1182,21 @@ CREATE TABLE
constant_value nvarchar(max) NULL
);
+/*
+Landing table for the raw parameter attributes shredded straight out of each
+plan's ParameterList. Reading them once here, then cleaning the values in a
+second pass over these plain-string columns, avoids re-parsing the plan XML
+several times per parameter (see the note at the parameter-extraction step).
+*/
+CREATE TABLE
+ #parameter_shred
+(
+ plan_id bigint NOT NULL,
+ param_column sysname NULL,
+ param_data_type sysname NULL,
+ param_compiled_value nvarchar(max) NULL
+);
+
/*
If @query_plan_xml was supplied, seed the repro pipeline with one synthetic
plan so the parser, warnings, and repro-builder can all run without Query
@@ -4164,43 +4179,23 @@ SELECT
@current_table = N'extracting parameters from query plans';
INSERT
- #query_parameters
+ #parameter_shred
WITH
(TABLOCK)
(
plan_id,
- parameter_name,
- parameter_data_type,
- parameter_compiled_value
+ param_column,
+ param_data_type,
+ param_compiled_value
)
SELECT
qsp.plan_id,
- parameter_name =
- LTRIM(RTRIM(cr.c.value(N'@Column', N'sysname'))),
- parameter_data_type =
- LTRIM(RTRIM(cr.c.value(N'@ParameterDataType', N'sysname'))),
- parameter_compiled_value =
- CASE
- /*Strip parentheses from values like (13)*/
- WHEN cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)') LIKE N'(%)'
- THEN SUBSTRING
- (
- cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)'),
- 2,
- LEN(cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)')) - 2
- )
- /*Strip guid wrapper from values like {guid'...'}*/
- WHEN cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)') LIKE N'{guid''%''}'
- THEN N'''' +
- SUBSTRING
- (
- cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)'),
- 7,
- LEN(cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)')) - 8
- ) +
- N''''
- ELSE cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)')
- END
+ param_column =
+ cr.c.value(N'@Column', N'sysname'),
+ param_data_type =
+ cr.c.value(N'@ParameterDataType', N'sysname'),
+ param_compiled_value =
+ cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)')
FROM #query_store_plan AS qsp
CROSS APPLY
(
@@ -4231,6 +4226,59 @@ WHERE CHARINDEX(N'', qsp.query_plan) > 0
AND x.parameter_list_xml IS NOT NULL
OPTION(RECOMPILE);
+/*
+Clean the parameter values in a second pass over the materialized columns.
+
+The earlier version read cr.c.value(N'@ParameterCompiledValue') four times in
+this one statement, plus @Column and @ParameterDataType - nine .value() calls on
+the same node. Against untyped XML the optimizer builds a separate XML Reader
+table-valued function per call, ten of them stacked in nested-loop joins, and
+each one re-parses the plan from scratch. That made this the procedure's single
+largest cost on many-plan workloads (measured 13x slower on 500 plans than the
+shred-once form below). Reading each attribute once into #parameter_shred and
+applying the cleanup here on plain strings produces identical values.
+*/
+INSERT
+ #query_parameters
+WITH
+ (TABLOCK)
+(
+ plan_id,
+ parameter_name,
+ parameter_data_type,
+ parameter_compiled_value
+)
+SELECT
+ ps.plan_id,
+ parameter_name =
+ LTRIM(RTRIM(ps.param_column)),
+ parameter_data_type =
+ LTRIM(RTRIM(ps.param_data_type)),
+ parameter_compiled_value =
+ CASE
+ /*Strip parentheses from values like (13)*/
+ WHEN ps.param_compiled_value LIKE N'(%)'
+ THEN SUBSTRING
+ (
+ ps.param_compiled_value,
+ 2,
+ LEN(ps.param_compiled_value) - 2
+ )
+ /*Strip guid wrapper from values like {guid'...'}*/
+ WHEN ps.param_compiled_value LIKE N'{guid''%''}'
+ THEN N'''' +
+ SUBSTRING
+ (
+ ps.param_compiled_value,
+ 7,
+ LEN(ps.param_compiled_value) - 8
+ ) +
+ N''''
+ ELSE ps.param_compiled_value
+ END
+FROM #parameter_shred AS ps
+OPTION(RECOMPILE);
+
/*
Fill in parameters declared in query text
but missing from the plan's ParameterList.
From d7f783a198e1967f6a98ef0578fec7dfb5406d0c Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 17:36:40 -0400
Subject: [PATCH 24/60] sp_PerfCheck: add a deterministic assertion harness
sp_PerfCheck had no tests. Most of its findings read volatile server state -
offline schedulers, latency, memory pressure, deadlock counts - which cannot be
forced deterministically, so a naive harness would flake. This one asserts only
what it can control or what is structurally invariant.
run_tests.py, 48 assertions:
- Structural/smoke: the proc runs without error, returns a populated
#server_info set, produces a well-formed #results set (every row has
check_id/priority/category/finding), @help returns help and no findings,
@debug runs clean. These catch crashes and shape regressions across versions.
- Forced server configuration: the "Non-Default Configuration" check
(check_id 1000) is exercised bidirectionally against sp_configure options
that are safe to toggle - cost threshold for parallelism, optimize for ad hoc
workloads, access check cache bucket count. Each is asserted absent at its
default and present when forced, then restored to its exact original
value_in_use. A forced-equals-default variant is confirmed to go red, so the
assertions are not rubber stamps.
- Forced database configuration on a throwaway scratch database: Auto-Shrink
Enabled and Auto Update Statistics Disabled, toggled by reversible
ALTER DATABASE. Each serves as the other's positive control.
Every absence assertion has a positive control (server_info populated, or a
paired forced finding scoped by database_name) so an empty result set cannot pass
vacuously. Zero net configuration change is asserted by the harness's own
before/after sys.configurations snapshot and was independently confirmed by
diffing sys.configurations around a full run. Idempotent across repeated runs;
green on 2016/2017/2019/2022/2025.
README documents coverage and, importantly, an honest list of what is NOT covered
and why - the volatile-state and restart-bound checks that cannot be tested
deterministically.
Does not touch sp_PerfCheck.sql.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/tests/README.md | 186 +++++++++++
sp_PerfCheck/tests/run_tests.py | 563 ++++++++++++++++++++++++++++++++
2 files changed, 749 insertions(+)
create mode 100644 sp_PerfCheck/tests/README.md
create mode 100644 sp_PerfCheck/tests/run_tests.py
diff --git a/sp_PerfCheck/tests/README.md b/sp_PerfCheck/tests/README.md
new file mode 100644
index 00000000..c6dec1fa
--- /dev/null
+++ b/sp_PerfCheck/tests/README.md
@@ -0,0 +1,186 @@
+# sp_PerfCheck Tests
+
+`sp_PerfCheck` is a read-only server-health diagnostic. It inspects DMVs,
+`sys.configurations`, and server state and returns two result sets:
+
+1. `#server_info` -- columns `[Server Information]`, `[Details]`
+2. `#results` -- columns `check_id`, `priority`, `priority_label`, `category`,
+ `finding`, `database_name`, `object_name`, `details`, `url`
+
+**Run the automated suite before and after any change to `sp_PerfCheck.sql`.**
+Compiling proves nothing about behavior. This suite catches crashes, output-shape
+regressions, and regressions in the checks whose firing conditions can be created
+deterministically.
+
+## The hard problem: determinism on a shared instance
+
+Almost every `sp_PerfCheck` finding depends on **volatile server state** you
+cannot control on a shared test box -- offline schedulers, read/write latency,
+memory-starved queries, deadlock counts, memory dumps, accumulated wait
+statistics. Asserting on any of those produces a suite that flakes, and a suite
+that flakes is worse than no suite. So this harness asserts **only** on things
+that are controllable or structurally invariant, and it is honest (below) about
+the large surface it therefore does not cover.
+
+## Automated
+
+| Script | What it does |
+| --- | --- |
+| `run_tests.py` | Drives `dbo.sp_PerfCheck` over `sqlcmd`, parses the two result sets, and asserts **48** expectations across three groups: 11 structural/smoke, 26 forced server-configuration, 11 forced database-configuration. Self-cleaning and idempotent. |
+
+```
+cd sp_PerfCheck/tests
+python run_tests.py --server SQL2022
+```
+
+`--server` and `--password` default to `SQL2022` / the standard local `sa`
+password. Expect `48 passed, 0 failed`. The proc must be installed in `master`
+on the target instance (there is a preflight that fails fast if it is not).
+
+The suite has been run green on SQL Server 2016, 2017, 2019, 2022, and 2025.
+
+### What it covers
+
+**1. Structural / smoke (11).** These catch crashes and output-shape regressions
+across versions, and are always valid regardless of instance state:
+
+- default run raises no severe SQL error (severity 16+);
+- default run populates `#server_info` (the always-present `Run Date` row);
+- default run emits the `#results` set with all nine expected column names;
+- every `#results` row is well formed -- carries a `check_id`, `priority`,
+ `category`, and `finding`;
+- `@help = 1` returns help text and short-circuits (no findings, no
+ `#server_info`);
+- `@debug = 1` runs clean, prints diagnostics, and still completes.
+
+**2. Forced server-configuration (26).** The "Non-Default Configuration" check
+(`check_id 1000`) reads `sys.configurations`. For three options that are **safe
+to flip on a test instance** the harness proves the finding **bidirectionally**:
+
+| Option | Default | Forced to | Proven |
+| --- | --- | --- | --- |
+| `cost threshold for parallelism` | 5 | 55 | absent at 5, present at 55 |
+| `optimize for ad hoc workloads` | 0 | 1 | absent at 0, present at 1 |
+| `access check cache bucket count` | 0 | 256 | absent at 0, present at 256 |
+
+For each option the harness asserts the finding is absent at the default value,
+present when forced (with `check_id 1000`, `priority 50`,
+`category = Server Configuration`, and the option name plus forced value in
+`details`), and that no severe error occurred. Each **absence** assertion is
+paired with a **positive control** -- `#server_info` is populated on the same run
+-- so an empty or failed result set cannot pass vacuously; the matching presence
+assertion is the proof that the check itself actually runs.
+
+These three options were chosen because none require a restart, none are
+dangerous (no `max server memory`, no affinity, no MAXDOP), and nothing the CI
+depends on reads them. Each option's **original `value_in_use` is captured first
+and restored precisely** in a `finally` block that runs even on assertion
+failure, and the harness dumps the entire `sys.configurations` table before and
+after and asserts **zero net change**. The suite is idempotent: run it twice,
+same result, no leaked config.
+
+**3. Forced database-configuration (11).** Two database-level checks read pure,
+instantly-reversible metadata, so a **throwaway scratch database**
+(`perfcheck_test_scratch`, created and dropped by the harness) can force each on
+and off:
+
+| Check | check_id | Forced via |
+| --- | --- | --- |
+| Auto-Shrink Enabled | 7001 | `ALTER DATABASE ... SET AUTO_SHRINK ON/OFF` |
+| Auto Update Statistics Disabled | 7004 | `ALTER DATABASE ... SET AUTO_UPDATE_STATISTICS OFF/ON` |
+
+The two toggles control each other's positive control: in each of two runs one
+finding is present (scoped to the scratch database **by name**, which proves the
+database was actually analyzed) while the other is absent (a real absence, not a
+skipped database). Across the two runs each toggle is proven both present and
+absent. The scratch database is dropped in a `finally` block, and setup is
+idempotent (it drops any leaked copy first).
+
+### Why the forced-condition assertions are trusted
+
+Every forced-condition assertion was watched fail-to-fire at the non-triggering
+state and fire at the triggering state before being trusted. The pairing is what
+makes it non-vacuous: if detection were broken so the finding never appeared, the
+**present** assertion would fail; if it always appeared, the **absent** assertion
+would fail. Both passing means detection is correct and bidirectional. A harness
+built with `forced == default` (so the "present" run could never fire) was
+confirmed to go red, proving the assertions are not rubber-stamps.
+
+## What is NOT covered, and why
+
+This is the honest part, and it is a required part. The following checks read
+**volatile or environment-bound state that cannot be created and reset
+deterministically** on a shared instance, so this harness deliberately does not
+assert on them. Do not read a green run as "all of `sp_PerfCheck` works" -- it
+means the covered surface works.
+
+**Volatile runtime state (cannot be forced without destabilizing the box, and
+would flake run-to-run):**
+
+- Offline CPU Schedulers (`check_id 935`)
+- Memory-Starved Queries Detected (`967`, `994`) -- depends on live resource
+ semaphore waits
+- Memory Dumps Detected In Last 90 Days (`1029`) -- depends on dump history
+- High Number of Deadlocks (`1106`) -- depends on the deadlock counter
+- Large Security Token Cache (`1172`)
+- Slow Read Latency / Slow Write Latency (`2834`, `2877`) -- from
+ `sys.dm_io_virtual_file_stats`; latency cannot be dialed to a threshold on
+ demand
+- Everything in the **Wait Statistics** and **Memory Usage** categories --
+ cumulative since restart, uncontrollable
+
+**Environment / OS state (would require an OS privilege change and a service
+restart, which CI cannot do):**
+
+- Lock Pages in Memory Not Enabled (`1220`)
+- Instant File Initialization Disabled (`1279`)
+
+**Deliberately-not-touched because forcing them is disruptive, dangerous, or
+requires a restart:**
+
+- TempDB Configuration checks (file count / size / growth parity) -- changing
+ tempdb's file layout needs a restart to take effect and destabilizes a shared
+ instance
+- Configuration Pending Reconfigure (`3826`) -- forcing it means deliberately
+ leaving a `RECONFIGURE`-pending dirty state on the box
+- Resource Governor Enabled (`1322`) -- toggling Resource Governor affects
+ workload classification for every session on the instance
+- `max server memory`, affinity masks, `max degree of parallelism`, priority
+ boost, query governor cost limit -- excluded from the forced-config set even
+ though check `1000` reads them, because they are dangerous, destabilizing, or
+ something a real workload may depend on
+
+**Additional database-level checks that ARE forceable but are not asserted
+(representative coverage only):** Auto-Close Enabled (`7002`), Query Store Not
+Enabled (`7005` region), ANSI Settings Require Review, Non-Default Target
+Recovery Time, Delayed Durability, Accelerated Database Recovery / RCSI, Ledger,
+and the file auto-growth checks (`7101`-`7104`) could each be forced on a scratch
+database. The harness asserts a representative pair (Auto-Shrink, Auto Update
+Statistics) to exercise the per-database cursor path and the `database_name`
+scoping without turning the suite into an exhaustive per-setting matrix. See the
+Auto-Close note below for why that specific one was dropped.
+
+### A real fragility this harness surfaced: AUTO_CLOSE on SQL Server 2025
+
+The database-scoped group was originally written to force **AUTO_CLOSE** as its
+second toggle. On **SQL Server 2025**, forcing `AUTO_CLOSE ON` and then scoping
+`sp_PerfCheck` to that (now closed) database makes `sys.databases` return `NULL`
+for `collation_name`, `target_recovery_time_in_seconds`, and
+`delayed_durability_desc`, which the procedure's `#databases` insert rejects with
+`Msg 515` (`Cannot insert the value NULL`). The database is never analyzed and
+the run aborts. On 2016-2022 the same test passed, so this is a genuine,
+version-dependent fragility in `sp_PerfCheck` itself, not a test artifact.
+
+Rather than assert around a procedure error (which would make the suite flake by
+version) or edit the procedure, the harness **switched its second toggle to
+AUTO_UPDATE_STATISTICS**, which keeps the database open and behaves identically
+on every version. The AUTO_CLOSE behavior is documented here so it is not lost.
+
+## Not wired into CI
+
+Like the other DarlingData behavioral suites, this is **not** part of any GitHub
+Actions workflow -- it reconfigures a live instance (and briefly restores it),
+which is not something to run against shared CI infrastructure. Run it by hand
+against a test instance you own. It always restores every option it touches and
+drops every object it creates, but it is still a "run it on a box you control"
+tool by nature.
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
new file mode 100644
index 00000000..f5760b72
--- /dev/null
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -0,0 +1,563 @@
+"""
+sp_PerfCheck assertion test harness
+====================================
+sp_PerfCheck is a read-only server-health diagnostic. It inspects DMVs,
+sys.configurations, and server state, and returns two result sets:
+
+ 1. #server_info : columns [Server Information], [Details]
+ 2. #results : columns check_id, priority, priority_label, category,
+ finding, database_name, object_name, details, url
+
+Almost every finding depends on VOLATILE server state (offline schedulers, I/O
+latency, memory-starved queries, deadlock counts, memory dumps) that cannot be
+controlled on a shared instance, so this harness does NOT assert on any of them.
+A harness that flakes is worse than no harness. It asserts only on things that
+are controllable or structurally invariant:
+
+ * Structural / smoke: the proc runs without error; #server_info is populated;
+ a default run emits a well-formed #results set (every row carries a
+ check_id, priority, category, and finding); @help returns help text and no
+ findings; @debug = 1 runs clean and prints diagnostics.
+
+ * Forced-condition (the high-value core): conditions this harness can safely
+ create and reset, proven BIDIRECTIONALLY (finding absent when the condition
+ is not present, finding present when it is):
+ - Server config: the "Non-Default Configuration" check (check_id 1000)
+ reads sys.configurations. For a small set of SAFE options the harness
+ forces each to a non-default value and back, and names the option in the
+ details. Every option's original value_in_use is captured first and
+ restored precisely in a finally block that runs even on assertion
+ failure. The instance is never left reconfigured.
+ - Database config: Auto-Shrink Enabled (7001) and Auto Update Statistics
+ Disabled (7004) are forced on and off on a throwaway scratch database
+ that is created and dropped by the harness.
+
+Each absence assertion is paired with a positive control (#server_info
+populated) so an empty or failed result set cannot pass vacuously, and with the
+matching presence assertion, which proves the check actually runs.
+
+After all tests the full sys.configurations snapshot is compared before/after to
+prove zero net configuration change. The suite is idempotent: run it twice, same
+result, no leaked config.
+
+Usage:
+ python run_tests.py [--server SQL2022] [--password L!nt0044]
+
+Exits 1 if any assertion fails.
+"""
+
+import argparse
+import re
+import subprocess
+import sys
+
+
+# The "Non-Default Configuration" check (check_id 1000) fires when an option's
+# value_in_use differs from the listed default. These three are SAFE to flip on
+# a test instance: none require a restart, none are dangerous (no max server
+# memory, no affinity), and nothing the CI does depends on them. Each is:
+# (option name, default value, forced non-default value)
+# All three are "advanced" options, so 'show advanced options' must be 1 while
+# they are configured; the harness sets it and restores it.
+FORCED_OPTIONS = [
+ ("cost threshold for parallelism", 5, 55),
+ ("optimize for ad hoc workloads", 0, 1),
+ ("access check cache bucket count", 0, 256),
+]
+
+# Column names expected in the #results header (shape-regression guard).
+RESULTS_COLUMNS = [
+ "check_id", "priority", "priority_label", "category", "finding",
+ "database_name", "object_name", "details", "url",
+]
+
+# A diagnostic line the procedure prints only under @debug = 1.
+DEBUG_MARKER = "Collecting server information"
+
+# The last row the procedure always adds to #server_info (line ~5150). Its
+# presence proves the proc ran to completion and emitted #server_info, which is
+# the deterministic positive control for every absence assertion.
+SERVER_INFO_MARKER = "Run Date"
+
+
+def find_sql_errors(text):
+ """Return any SQL errors of severity 16 or higher found in text.
+
+ go-sqlcmd reports errors on stdout, so both streams have to be checked.
+ Matching the severity numerically catches Level 16 through 19 rather than
+ only the literal "Level 16".
+ """
+ if not text:
+ return []
+ return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text)
+
+
+def _sqlcmd(server, password, sql, headers=True):
+ """Run a batch and return (stdout, stderr) decoded as UTF-8.
+
+ Capturing bytes and decoding as UTF-8 keeps any non-ASCII server/db name
+ from being mangled by the Windows console code page (text=True would do
+ that). Output is tab-delimited and trimmed (-W -s TAB) and the line width is
+ maxed (-w 65535) so wide rows are not wrapped mid-row.
+ """
+ cmd = [
+ "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ "-d", "master",
+ "-W", # trim trailing spaces
+ "-w", "65535", # do not wrap wide rows
+ "-s", "\t", # tab delimiter
+ ]
+ if not headers:
+ cmd += ["-h", "-1"]
+ cmd += ["-Q", sql]
+ r = subprocess.run(cmd, capture_output=True, timeout=300)
+ out = (r.stdout or b"").decode("utf-8", errors="replace")
+ err = (r.stderr or b"").decode("utf-8", errors="replace")
+ return out, err
+
+
+def _esc(s):
+ """Escape a T-SQL single-quoted literal."""
+ return s.replace("'", "''")
+
+
+def run_perfcheck(server, password, args=""):
+ """Run sp_PerfCheck and return combined (stdout, stderr)."""
+ sql = "EXECUTE dbo.sp_PerfCheck %s;" % args if args else "EXECUTE dbo.sp_PerfCheck;"
+ return _sqlcmd(server, password, sql)
+
+
+def run_perfcheck_with_option(server, password, name, value):
+ """Set one config option, RECONFIGURE, and run sp_PerfCheck in one batch so
+ the run is guaranteed to see the new value_in_use."""
+ sql = (
+ "SET NOCOUNT ON; "
+ "EXECUTE sys.sp_configure '%s', %d; "
+ "RECONFIGURE; "
+ "EXECUTE dbo.sp_PerfCheck;"
+ ) % (_esc(name), value)
+ return _sqlcmd(server, password, sql)
+
+
+def set_option(server, password, name, value):
+ """Set one config option and RECONFIGURE. Returns any severe errors."""
+ sql = (
+ "SET NOCOUNT ON; "
+ "EXECUTE sys.sp_configure '%s', %d; "
+ "RECONFIGURE;"
+ ) % (_esc(name), value)
+ out, err = _sqlcmd(server, password, sql)
+ return find_sql_errors(out) + find_sql_errors(err)
+
+
+def get_value_in_use(server, password, name):
+ """Return the current value_in_use of a config option as an int."""
+ sql = (
+ "SET NOCOUNT ON; "
+ "SELECT CONVERT(varchar(20), c.value_in_use) "
+ "FROM sys.configurations AS c WHERE c.name = '%s';"
+ ) % _esc(name)
+ out, _ = _sqlcmd(server, password, sql, headers=False)
+ for line in out.splitlines():
+ line = line.strip()
+ if line and (line.lstrip("-").isdigit()):
+ return int(line)
+ raise RuntimeError("could not read value_in_use for %r; output=%r" % (name, out[:200]))
+
+
+def snapshot_config(server, password):
+ """Return the full sys.configurations state as a sorted list of
+ 'name||value||value_in_use' lines, for a before/after zero-change diff."""
+ sql = (
+ "SET NOCOUNT ON; "
+ "SELECT CONVERT(nvarchar(200), c.name) + N'||' + "
+ "CONVERT(nvarchar(50), c.value) + N'||' + "
+ "CONVERT(nvarchar(50), c.value_in_use) "
+ "FROM sys.configurations AS c ORDER BY c.name;"
+ )
+ out, _ = _sqlcmd(server, password, sql, headers=False)
+ return sorted(l.strip() for l in out.splitlines() if "||" in l)
+
+
+def parse_result_rows(stdout):
+ """Return the #results data rows as lists of tab-split fields.
+
+ A real #results row starts with an integer check_id and has at least 8
+ fields (check_id .. details). Continuation lines produced by embedded CR/LF
+ inside a details value are only the tail of details plus url, so they carry
+ few tabs and are filtered out. #server_info rows start with a text
+ info_type, not a digit, so they are excluded too.
+ """
+ rows = []
+ for line in stdout.splitlines():
+ fields = line.split("\t")
+ if len(fields) >= 8 and fields[0].strip().isdigit():
+ rows.append([f.strip() for f in fields])
+ return rows
+
+
+def find_config_finding(rows, option_name):
+ """Return the #results row for a Non-Default Configuration finding on the
+ given option, or None."""
+ want = "Non-Default Configuration: " + option_name
+ for r in rows:
+ if r[4] == want:
+ return r
+ return None
+
+
+def find_db_finding(rows, finding_text, database_name):
+ """Return the #results row whose finding and database_name match, or None."""
+ for r in rows:
+ if r[4] == finding_text and len(r) > 5 and r[5] == database_name:
+ return r
+ return None
+
+
+def results_header_present(stdout):
+ """True if the #results result set header was emitted."""
+ for line in stdout.splitlines():
+ if "check_id" in line and "finding" in line and "url" in line:
+ return True
+ return False
+
+
+def results_header_columns_ok(stdout):
+ """True if the #results header carries every expected column name."""
+ for line in stdout.splitlines():
+ if "check_id" in line and "finding" in line and "url" in line:
+ return all(col in line for col in RESULTS_COLUMNS)
+ return False
+
+
+class Results:
+ def __init__(self):
+ self.items = []
+
+ def check(self, group, name, condition, detail=""):
+ self.items.append({
+ "group": group,
+ "name": name,
+ "passed": bool(condition),
+ "detail": detail,
+ })
+
+ @property
+ def passed(self):
+ return sum(1 for r in self.items if r["passed"])
+
+ @property
+ def failed(self):
+ return sum(1 for r in self.items if not r["passed"])
+
+
+def structural_tests(server, password, R):
+ """Structural / smoke assertions: crashes and shape regressions."""
+ # ---- Default-parameters run -----------------------------------------
+ out, err = run_perfcheck(server, password)
+ combined = out + "\n" + err
+ errors = find_sql_errors(combined)
+ R.check("Smoke", "default run: no severe SQL error",
+ not errors, str(errors))
+ R.check("Smoke", "default run: #server_info populated (Run Date present)",
+ SERVER_INFO_MARKER in out, "Run Date not found")
+ R.check("Smoke", "default run: #results result set emitted",
+ results_header_present(out), "results header not found")
+ R.check("Smoke", "default run: #results header has all expected columns",
+ results_header_columns_ok(out), "one or more columns missing")
+
+ rows = parse_result_rows(out)
+ malformed = []
+ for r in rows:
+ ok = (r[0].isdigit() and r[1].isdigit() and r[3] != "" and r[4] != "")
+ if not ok:
+ malformed.append(r[:5])
+ R.check("Smoke", "default run: every #results row well-formed "
+ "(check_id, priority, category, finding)",
+ len(malformed) == 0,
+ "%d rows parsed, %d malformed: %s" % (len(rows), len(malformed), malformed[:3]))
+
+ # ---- @help = 1 -------------------------------------------------------
+ out, err = run_perfcheck(server, password, "@help = 1")
+ combined = out + "\n" + err
+ R.check("Smoke", "@help = 1: no severe SQL error",
+ not find_sql_errors(combined), "errors present")
+ R.check("Smoke", "@help = 1: returns help text",
+ "i am sp_PerfCheck" in out, "help text not found")
+ R.check("Smoke", "@help = 1: emits no findings (short-circuits)",
+ (SERVER_INFO_MARKER not in out) and (not results_header_present(out)),
+ "server_info or results were emitted under @help")
+
+ # ---- @debug = 1 ------------------------------------------------------
+ out, err = run_perfcheck(server, password, "@debug = 1")
+ combined = out + "\n" + err
+ R.check("Smoke", "@debug = 1: no severe SQL error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("Smoke", "@debug = 1: prints diagnostic messages",
+ DEBUG_MARKER in combined, "debug marker %r not found" % DEBUG_MARKER)
+ R.check("Smoke", "@debug = 1: still completes (Run Date present)",
+ SERVER_INFO_MARKER in out, "Run Date not found under @debug")
+
+
+def forced_condition_tests(server, password, R):
+ """Bidirectional forced-condition assertions on the check_id 1000
+ Non-Default Configuration check. Captures and restores exact originals."""
+ # Snapshot the entire config before touching anything.
+ before = snapshot_config(server, password)
+
+ # Capture originals: 'show advanced options' plus every forced option.
+ saw_advanced = get_value_in_use(server, password, "show advanced options")
+ originals = {}
+ for (name, _default, _forced) in FORCED_OPTIONS:
+ originals[name] = get_value_in_use(server, password, name)
+
+ def restore_all():
+ # Restore every touched option to its captured original, then restore
+ # 'show advanced options'. One RECONFIGURE at the end promotes them all.
+ for (name, _d, _f) in FORCED_OPTIONS:
+ _sqlcmd(server, password,
+ "SET NOCOUNT ON; EXECUTE sys.sp_configure '%s', %d; RECONFIGURE;"
+ % (_esc(name), originals[name]))
+ _sqlcmd(server, password,
+ "SET NOCOUNT ON; EXECUTE sys.sp_configure 'show advanced options', %d; RECONFIGURE;"
+ % saw_advanced)
+
+ try:
+ # All chosen options are advanced; make sure they are configurable.
+ err = set_option(server, password, "show advanced options", 1)
+ R.check("Config", "setup: 'show advanced options' configurable",
+ not err, str(err))
+
+ for (name, default_value, forced_value) in FORCED_OPTIONS:
+ grp = "Config[%s]" % name
+
+ # ---- ABSENT at the default value ----------------------------
+ out, e = run_perfcheck_with_option(server, password, name, default_value)
+ combined = out + "\n" + e
+ R.check(grp, "no severe error on default-value run",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ # positive control: the check machinery actually ran and produced
+ # output, so "absent" is not a vacuous pass.
+ R.check(grp, "positive control: #server_info populated at default",
+ SERVER_INFO_MARKER in out, "Run Date not found")
+ rows = parse_result_rows(out)
+ R.check(grp, "finding ABSENT when option = default (%d)" % default_value,
+ find_config_finding(rows, name) is None,
+ "unexpected finding present at default")
+
+ # ---- PRESENT when forced to a non-default value -------------
+ out, e = run_perfcheck_with_option(server, password, name, forced_value)
+ combined = out + "\n" + e
+ R.check(grp, "no severe error on forced-value run",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ rows = parse_result_rows(out)
+ row = find_config_finding(rows, name)
+ R.check(grp, "finding PRESENT when option = forced (%d)" % forced_value,
+ row is not None, "finding not emitted when forced")
+ if row is not None:
+ well_formed = (row[0] == "1000" and row[1] == "50"
+ and row[3] == "Server Configuration")
+ R.check(grp, "forced finding row well-formed "
+ "(check_id 1000, priority 50, Server Configuration)",
+ well_formed,
+ "check_id=%s priority=%s category=%s" % (row[0], row[1], row[3]))
+ details = row[7] if len(row) > 7 else ""
+ names_option = (name in details
+ and ("Current: %d" % forced_value) in details)
+ R.check(grp, "forced finding details names the option and value",
+ names_option, "details=%r" % details[:160])
+ else:
+ R.check(grp, "forced finding row well-formed "
+ "(check_id 1000, priority 50, Server Configuration)",
+ False, "no row to inspect")
+ R.check(grp, "forced finding details names the option and value",
+ False, "no row to inspect")
+
+ # ---- Restore THIS option to its exact original --------------
+ set_option(server, password, name, originals[name])
+ now = get_value_in_use(server, password, name)
+ R.check(grp, "option restored to original value_in_use (%d)" % originals[name],
+ now == originals[name], "value_in_use is now %d" % now)
+ finally:
+ # Safety net: restore everything even if an exception was raised.
+ restore_all()
+
+ # Zero net configuration change after the whole run.
+ after = snapshot_config(server, password)
+ diff = [x for x in after if x not in set(before)] + \
+ [x for x in before if x not in set(after)]
+ R.check("Config", "zero net configuration change (sys.configurations before == after)",
+ before == after,
+ "differences: %s" % diff[:6] if diff else "")
+
+
+def database_scoped_tests(server, password, R):
+ """Bidirectional forced-condition assertions on two database-level checks
+ that read pure metadata: Auto-Shrink Enabled (check_id 7001) and Auto Update
+ Statistics Disabled (check_id 7004). Both are instant, reversible ALTER
+ DATABASE settings, so a scratch database can force each on and off
+ deterministically, and both leave the database OPEN.
+
+ (AUTO_CLOSE was deliberately NOT used. Forcing AUTO_CLOSE on and scoping the
+ procedure to the closed database makes sys.databases return NULL for
+ collation_name / target_recovery_time_in_seconds / delayed_durability_desc
+ on SQL Server 2025, which sp_PerfCheck's #databases insert rejects with
+ Msg 515. That is a real, version-dependent fragility in the procedure, not a
+ test artifact; it is documented in README.md rather than asserted, so this
+ harness stays deterministic across versions.)
+
+ The design keeps every absence assertion non-vacuous WITHOUT relying on any
+ volatile finding: in each run one toggle is set to its finding-producing
+ state and the other is not, so the finding that IS present (scoped to the
+ scratch database by name) proves the database was actually analyzed -- the
+ positive control -- while the other finding's absence is therefore real, not
+ the result of the database being skipped. Across the two runs each toggle is
+ proven both present and absent.
+ """
+ db = "perfcheck_test_scratch"
+ grp = "DbConfig"
+ SHRINK = "Auto-Shrink Enabled"
+ STATS = "Auto Update Statistics Disabled"
+
+ drop_sql = (
+ "SET NOCOUNT ON; "
+ "IF DB_ID('%s') IS NOT NULL "
+ "BEGIN "
+ "ALTER DATABASE [%s] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; "
+ "DROP DATABASE [%s]; "
+ "END;"
+ ) % (db, db, db)
+
+ def scoped_run():
+ return run_perfcheck(server, password, "@database_name = N'%s'" % db)
+
+ # Idempotent setup: drop any leaked copy, then create fresh.
+ out, err = _sqlcmd(server, password, drop_sql + " CREATE DATABASE [%s];" % db)
+ setup_err = find_sql_errors(out) + find_sql_errors(err)
+ R.check(grp, "setup: scratch database created",
+ not setup_err and DB_marker(server, password, db), str(setup_err))
+ if setup_err:
+ _sqlcmd(server, password, drop_sql)
+ return
+
+ try:
+ # ---- Run A: AUTO_SHRINK ON, AUTO_UPDATE_STATISTICS ON -----------
+ _sqlcmd(server, password,
+ "ALTER DATABASE [%s] SET AUTO_SHRINK ON; "
+ "ALTER DATABASE [%s] SET AUTO_UPDATE_STATISTICS ON;" % (db, db))
+ out, err = scoped_run()
+ R.check(grp, "run A: no severe SQL error",
+ not (find_sql_errors(out) + find_sql_errors(err)),
+ str(find_sql_errors(out) + find_sql_errors(err)))
+ R.check(grp, "run A: #server_info populated",
+ SERVER_INFO_MARKER in out, "Run Date not found")
+ rows = parse_result_rows(out)
+ shrink = find_db_finding(rows, SHRINK, db)
+ stats = find_db_finding(rows, STATS, db)
+ # Auto-Shrink PRESENT: this both proves the toggle fires AND proves the
+ # scratch database was analyzed (positive control for the absence below).
+ R.check(grp, "Auto-Shrink PRESENT when forced on (proves db analyzed)",
+ shrink is not None, "Auto-Shrink finding missing when forced on")
+ if shrink is not None:
+ R.check(grp, "Auto-Shrink row well-formed (check_id 7001, db scoped)",
+ shrink[0] == "7001" and shrink[3] == "Database Configuration",
+ "check_id=%s category=%s" % (shrink[0], shrink[3]))
+ R.check(grp, "Auto-Update-Stats-Disabled ABSENT when stats on "
+ "(db was analyzed, so non-vacuous)",
+ stats is None, "stats-disabled finding present when stats on")
+
+ # ---- Run B: AUTO_SHRINK OFF, AUTO_UPDATE_STATISTICS OFF ---------
+ _sqlcmd(server, password,
+ "ALTER DATABASE [%s] SET AUTO_SHRINK OFF; "
+ "ALTER DATABASE [%s] SET AUTO_UPDATE_STATISTICS OFF;" % (db, db))
+ out, err = scoped_run()
+ R.check(grp, "run B: no severe SQL error",
+ not (find_sql_errors(out) + find_sql_errors(err)),
+ str(find_sql_errors(out) + find_sql_errors(err)))
+ rows = parse_result_rows(out)
+ shrink = find_db_finding(rows, SHRINK, db)
+ stats = find_db_finding(rows, STATS, db)
+ # Stats-disabled PRESENT is now the positive control that db was analyzed.
+ R.check(grp, "Auto-Update-Stats-Disabled PRESENT when stats off "
+ "(proves db analyzed)",
+ stats is not None, "stats-disabled finding missing when stats off")
+ if stats is not None:
+ R.check(grp, "Stats-disabled row well-formed (check_id 7004, db scoped)",
+ stats[0] == "7004" and stats[3] == "Database Configuration",
+ "check_id=%s category=%s" % (stats[0], stats[3]))
+ R.check(grp, "Auto-Shrink ABSENT when off (db was analyzed, so non-vacuous)",
+ shrink is None, "Auto-Shrink finding present when off")
+ finally:
+ # Always drop the scratch database, even on assertion failure.
+ _sqlcmd(server, password, drop_sql)
+ R.check(grp, "cleanup: scratch database dropped",
+ not DB_marker(server, password, db), "scratch database still exists")
+
+
+def DB_marker(server, password, db):
+ """True if the named database currently exists."""
+ out, _ = _sqlcmd(
+ server, password,
+ "SET NOCOUNT ON; SELECT CASE WHEN DB_ID('%s') IS NOT NULL "
+ "THEN 'YES' ELSE 'NO' END;" % _esc(db),
+ headers=False,
+ )
+ return "YES" in out
+
+
+def preflight(server, password):
+ """Confirm sp_PerfCheck is installed before running anything."""
+ out, err = _sqlcmd(
+ server, password,
+ "SET NOCOUNT ON; SELECT CASE WHEN OBJECT_ID(N'dbo.sp_PerfCheck', N'P') "
+ "IS NOT NULL THEN 'OK' ELSE 'MISSING' END;",
+ headers=False,
+ )
+ return "OK" in out
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--server", default="SQL2022")
+ ap.add_argument("--password", default="L!nt0044")
+ args = ap.parse_args()
+
+ try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ except Exception:
+ pass
+
+ print("Running sp_PerfCheck assertion tests against %s..." % args.server)
+ print()
+
+ if not preflight(args.server, args.password):
+ print("ERROR: dbo.sp_PerfCheck is not installed in master on %s." % args.server)
+ print("Install sp_PerfCheck.sql before running this harness.")
+ sys.exit(1)
+
+ R = Results()
+ structural_tests(args.server, args.password, R)
+ forced_condition_tests(args.server, args.password, R)
+ database_scoped_tests(args.server, args.password, R)
+
+ for r in R.items:
+ status = "PASS" if r["passed"] else "FAIL"
+ detail = (" (%s)" % r["detail"]) if (not r["passed"] and r["detail"]) else ""
+ print(" [%s] %s: %s%s" % (status, r["group"], r["name"], detail))
+
+ print()
+ print("Results: %d passed, %d failed, %d total" % (R.passed, R.failed, len(R.items)))
+
+ if R.failed > 0:
+ print()
+ print("FAILED TESTS:")
+ for r in R.items:
+ if not r["passed"]:
+ print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"]))
+ sys.exit(1)
+ else:
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
From 5988e924ee55cf95b8391f3455e6c1acdf076c16 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 18:15:05 -0400
Subject: [PATCH 25/60] sp_HumanEvents: add a DDL-validity and session-hygiene
harness
sp_HumanEvents had no tests. It builds a CREATE EVENT SESSION statement per
event type and filter set, so its characteristic failure is generating malformed
session DDL for some combination - the analog of the "generated script that will
not run" bugs the other procs carried. Testing that does not require slow live
event captures: @keep_alive = 1 creates the session and returns without sampling,
so the harness creates it, confirms SQL Server accepted it, and drops it.
run_tests.py, 195 assertions:
- DDL-validity matrix (the core): every accepted @event_type spelling (all 18
of waits/wait, blocking/locking/..., queries/query, compiles/..., recompiles/
...) plus twelve filter combinations - @query_duration_ms, @database_name,
@requested_memory_mb, client app/hostname/username, sampled sessions,
object-name/schema, @skip_plans, @custom_name, @wait_type CSV and ALL,
@blocking_duration_ms. Each @keep_alive = 1 case asserts no error, that the
session was created carrying exactly the expected event set (read from
sys.server_event_session_events, which proves SQL Server accepted each
event), then drops it.
- @debug DDL capture: @session_sql per category contains CREATE EVENT SESSION
and the expected events.
- Short live-sample smoke: a couple of event types at @seconds_sample = 3,
@keep_alive = 0, asserting the full create/sample/query/drop path completes
on an empty capture without error and leaves no session behind.
- Logging-to-table collector mode against a throwaway database, then the proc's
own @cleanup = 1, asserting it removes the session, tables, and views.
- @help / structural / hygiene.
Session hygiene is enforced: sessions are swept by name at start and in a finally
block, each keep_alive session is dropped immediately, and the harness diffs
sys.server_event_sessions around the run to assert zero net sessions. Expected
event sets are computed from a live capability probe rather than hard-coded per
version, so the matrix is correct across builds. Green on 2016/2022/2025;
idempotent; verified zero net sessions and no scratch databases afterward.
Injecting a bogus expected event was confirmed to turn the suite red on exactly
the affected cases (assertions are not rubber stamps).
README documents coverage and, honestly, what is not covered: captured event
content (timing/workload-bound - the reason for the DDL-validity approach),
event_file targets, Azure database-scoped sessions, and non-dbo output schemas.
Does not touch sp_HumanEvents.sql.
Co-Authored-By: Claude Opus 4.8
---
sp_HumanEvents/tests/README.md | 194 ++++++++
sp_HumanEvents/tests/run_tests.py | 793 ++++++++++++++++++++++++++++++
2 files changed, 987 insertions(+)
create mode 100644 sp_HumanEvents/tests/README.md
create mode 100644 sp_HumanEvents/tests/run_tests.py
diff --git a/sp_HumanEvents/tests/README.md b/sp_HumanEvents/tests/README.md
new file mode 100644
index 00000000..a27de399
--- /dev/null
+++ b/sp_HumanEvents/tests/README.md
@@ -0,0 +1,194 @@
+# sp_HumanEvents Tests
+
+`sp_HumanEvents` builds an Extended Events session out of dynamic SQL, samples
+events for `@seconds_sample` seconds, then shreds and returns what it captured.
+Event types it understands (confirmed against the validation list in the proc):
+
+| Category | Accepted `@event_type` spellings | Events the session adds (SQL 2022) |
+| --- | --- | --- |
+| query | `query`, `queries` | `module_end`, `rpc_completed`, `sp_statement_completed`, `sql_statement_completed`, `query_post_execution_showplan` (unless `@skip_plans = 1`) |
+| waits | `waits`, `wait` | `wait_completed` (2014+) / `wait_info` (2012) |
+| blocking | `blocking`, `locking`, `block`, `blocks`, `lock`, `locks` | `blocked_process_report` |
+| compiles | `compiles`, `compile`, `compilation`, `compilations` | `sql_statement_post_compile` (2017+) or `uncached_sql_batch_statistics` + `sql_statement_recompile` (older); plus `query_parameterization_data` where available |
+| recompiles | `recompiles`, `recompile`, `recompilation`, `recompilations` | `sql_statement_post_compile` (2017+) or `sql_statement_recompile` (older) |
+
+**Run the automated suite before and after any change to `sp_HumanEvents.sql`.**
+Compiling proves nothing about behavior. This suite catches the proc's
+characteristic failure directly: **a generated `CREATE EVENT SESSION` that will
+not run** for some event-type / filter-parameter combination on the target
+server version.
+
+## The hard problem: don't test live event content
+
+Almost everything `sp_HumanEvents` *returns* depends on **volatile, timing-bound
+workload** -- which queries happened to run, which waits happened to fire, which
+blocking happened to occur during the few-second sample. Asserting on captured
+rows produces a suite that flakes, and a flaky suite is worse than none. So this
+harness does **not** assert on captured event rows. It asserts on the thing that
+actually breaks when the proc regresses: **whether the dynamic DDL it generates
+is valid and is accepted by SQL Server**, plus strict **session hygiene**.
+
+Two levers in the proc make that deterministic and fast, with no slow captures:
+
+- **`@keep_alive = 1`** creates a *permanent* session and `RETURN`s immediately
+ -- no `WAITFOR` sample, no drop (the `@keep_alive = 1` branch near line 1777).
+ A session that CREATEs successfully is proof the generated DDL is valid on this
+ server version. This is the high-value core.
+- **`@debug = 1`** prints the generated `@session_sql` via `RAISERROR` (near line
+ 1768), so the exact DDL can be captured from stdout and inspected.
+
+## Automated
+
+| Script | What it does |
+| --- | --- |
+| `run_tests.py` | Drives `dbo.sp_HumanEvents` over `sqlcmd`, exercising every accepted `@event_type` and a representative filter matrix with `@keep_alive = 1`, and asserts **195** expectations across seven groups. Self-cleaning and idempotent. |
+
+```
+cd sp_HumanEvents/tests
+python run_tests.py --server SQL2022
+```
+
+`--server` and `--password` default to `SQL2022` / the standard local `sa`
+password. Expect `195 passed, 0 failed`. The proc must be installed in `master`
+on the target instance (there is a preflight that fails fast if it is not, and a
+guard that refuses Azure SQL DB, whose sessions are database-scoped).
+
+The suite has been run green on **SQL Server 2016, 2022, and 2025** (and the
+structural + DDL matrix is version-parameterized for 2017/2019 as well). The
+expected event set for each `@event_type` is not hard-coded to a version: the
+harness **probes the server** for `sql_statement_post_compile`,
+`query_parameterization_data`, and the version number, and computes the expected
+events accordingly, so it stays correct whether or not a given build exposes the
+newer compile/parameterization events.
+
+### What it covers
+
+**1. Structural / smoke (6).** `@help = 1` returns help text and creates no
+session; an invalid `@event_type` is rejected with the proc's validation message
+(severity 11) and creates no session -- a bidirectional check that validation
+actually fires, not just that valid input works.
+
+**2. DDL-validity matrix -- base (90).** For **each of the 18 accepted
+`@event_type` spellings**, the harness runs `@keep_alive = 1` and asserts, five
+ways: the call raised no severe SQL error; the proc itself raised no error; a
+session with the expected generated name now exists in
+`sys.server_event_sessions`; the session carries **exactly** the set of events
+that event type is supposed to add (read from the authoritative catalog
+`sys.server_event_session_events`, which cannot be truncated and proves SQL
+Server actually *accepted* each event); and the session is then dropped and
+confirmed gone.
+
+**3. DDL-validity matrix -- filters (60).** Twelve representative
+filter-parameter combinations across the categories, each asserted the same five
+ways. Between them they exercise `@query_duration_ms`, `@database_name`,
+`@requested_memory_mb`, `@client_app_name`, `@client_hostname`, `@username`,
+`@session_id = 'sample'` + `@sample_divisor`, `@object_name` (+ `@object_schema`
+for the blocking object-id path), `@skip_plans = 1` (asserts the showplan event
+is **dropped** -- four events, not five), `@custom_name` (asserts the session
+name gains the custom suffix), `@wait_type` as a CSV and as `ALL`,
+`@wait_duration_ms`, `@gimme_danger`, and `@blocking_duration_ms`.
+
+**4. Debug-DDL capture (21).** For each of the five categories, `@debug = 1` is
+captured and the generated `@session_sql` is asserted to contain
+`CREATE EVENT SESSION` plus each event the category adds. (`RAISERROR` truncates
+a long message near 2044 characters, so the untruncated
+`sys.server_event_session_events` check in groups 2 and 3 is the authoritative
+one; this group is a secondary guard, plus a check that `@debug` emits its
+diagnostic markers.)
+
+**5. Short live sample (6).** Two event types (`waits`, `query`) are run for real
+with `@seconds_sample = 3` and `@keep_alive = 0` -- the full
+create / sample / query / drop path. Each asserts no severe error, that the proc
+**completed on an empty capture without erroring** (the common failure mode for a
+quiet instance), and that the proc **dropped its own throwaway session**: the
+global count of `HumanEvents%` / `keeper_HumanEvents%` sessions is snapshotted
+before and after the call and must be equal.
+
+**6. Logging-to-table + cleanup (10).** `@keep_alive = 1` with
+`@output_database_name` enters an **unbounded** collector loop that creates
+permanent tables/views for keeper sessions and harvests into them forever. The
+tables are created in the first pass (well under a second), so the harness runs
+it with a `sqlcmd` query timeout (`-t`), which sends an attention that cancels
+the loop **server-side** (verified: a batch cancelled this way does not keep
+running). It then asserts the base table `keeper_HumanEvents_waits` and the three
+`HumanEvents_Waits*` views were created, exercises the proc's own `@cleanup = 1`
+teardown, and asserts the proc removed the session, tables, and views itself.
+Everything is created in and dropped with a throwaway scratch database
+(`sp_HumanEvents_test_scratch`).
+
+**7. Session hygiene (2).** Around the whole run, `sys.server_event_sessions` is
+diffed and the suite asserts **zero net new sessions**, then confirms nothing
+remains after the final sweep. The before/after lists and the diff are printed.
+
+### Session hygiene -- non-negotiable
+
+Extended Events sessions are server-global; a leaked running session is a real
+problem. The harness treats that as a first-class requirement:
+
+- `sp_HumanEvents` names its sessions predictably --
+ `HumanEvents__` (one-shot) and
+ `keeper_HumanEvents_[_]` (`@keep_alive = 1`). The
+ harness **sweeps both patterns at start** (idempotency: reap anything a prior
+ aborted run left) and **in a `finally` block at the end** (so leaks are cleaned
+ even when an assertion fails -- verified: a deliberately failing run still left
+ zero sessions behind).
+- Each `@keep_alive` matrix case **drops its own session immediately** after its
+ assertions, so at most one session exists at a time. This is not just tidiness:
+ each session reserves a 100 MB ring buffer, and creating all 18 at once demanded
+ ~1.8 GB and drew `Msg 701` (out of memory). That is a **test artifact, not a
+ proc bug**; dropping-each-first avoids it.
+- The short-sample cases assert the proc cleaned up **its own** session.
+- The run prints the before/after session lists and asserts they are identical.
+
+### Why the assertions are trusted
+
+The event-set assertions are not rubber-stamps. The `@skip_plans = 1` case
+expects **four** events and the plain query case expects **five**; both pass,
+which is only possible if the comparison actually discriminates. As an explicit
+negative control, injecting a bogus expected event was confirmed to turn the
+suite **red** on exactly the affected cases (9 failures) -- and the `finally`
+sweep still left **zero** leaked sessions on that failing run. Every event type
+was also watched to create a real session and report its real event list before
+the expected sets were written.
+
+## What is NOT covered, and why
+
+This is the honest part. A green run means the covered surface -- DDL validity
+and hygiene -- works; it does not mean every behavior of `sp_HumanEvents` is
+exercised.
+
+- **Captured event content.** By design (see above): which rows come back is
+ timing- and workload-bound and cannot be forced deterministically on a shared
+ instance. The live-sample group asserts the path *runs and cleans up*, not what
+ it returns. This is the large, deliberate gap.
+- **`@target_output = 'event_file'`.** Only the default `ring_buffer` target is
+ exercised. The `event_file` target writes `.xel` files to the SQL Server's own
+ filesystem, and the `@keep_alive` path does not delete them (only the one-shot
+ path calls `xp_delete_files`). The harness cannot reliably locate and remove a
+ server-side file, so asserting on this target would either leave disk artifacts
+ or be flaky. Its DDL differs only in the `ADD TARGET` clause.
+- **Azure SQL Database / Managed Instance.** The proc uses database-scoped
+ sessions and a different catalog there; the harness refuses to run against an
+ Azure engine edition rather than assert box-product invariants that do not hold.
+- **The pre-2017 event fallbacks specifically.** The expected-event logic *is*
+ version-aware (it would expect `uncached_sql_batch_statistics` /
+ `sql_statement_recompile` / `wait_info` on a build that lacks the newer events),
+ but every instance available here -- including the 2016 build (13.0.6300) --
+ exposes the newer `sql_statement_post_compile` / `query_parameterization_data`
+ events, so those specific fallback DDL strings were not observed to CREATE on a
+ box that actually lacks them. On a bare RTM 2016/2012 the harness would compute
+ the fallback set; that path has not been watched create.
+- **Non-`dbo` `@output_schema_name`.** The logging tests use `dbo`. The proc's
+ branch that validates a non-default output schema is not exercised.
+- **Blocking actually captured.** The blocking session's DDL validity is covered;
+ inducing a real blocked-process-report event is not.
+
+## Not wired into CI
+
+Like the other DarlingData behavioral suites, this is **not** part of any GitHub
+Actions workflow. It creates and drops server-global Extended Events sessions
+(and, for the blocking cases, will briefly set `blocked process threshold` if it
+is `0`, restoring it afterward), which is not something to run against shared CI
+infrastructure. Run it by hand against a test instance you own. It sweeps every
+session it creates and drops every object it creates, but it is a "run it on a
+box you control" tool by nature.
diff --git a/sp_HumanEvents/tests/run_tests.py b/sp_HumanEvents/tests/run_tests.py
new file mode 100644
index 00000000..69df0b59
--- /dev/null
+++ b/sp_HumanEvents/tests/run_tests.py
@@ -0,0 +1,793 @@
+"""
+sp_HumanEvents DDL-validity and session-hygiene test harness
+============================================================
+sp_HumanEvents builds an Extended Events session out of dynamic SQL, samples
+events for @seconds_sample seconds, then shreds and returns what it captured.
+Its characteristic failure is generating a malformed CREATE EVENT SESSION for
+some @event_type / filter-parameter combination -- a "generated script that will
+not run" on the target server version. Live event CONTENT is timing-dependent
+and thin, so this harness does NOT assert on captured rows; it asserts on the
+thing that actually breaks: whether the generated DDL is accepted by SQL Server.
+
+Two levers make that deterministic and fast, with no slow live captures:
+
+ * @keep_alive = 1 creates a PERMANENT session and RETURNs immediately -- no
+ WAITFOR sample, no drop (see the @keep_alive = 1 branch around line 1777 of
+ sp_HumanEvents.sql). A session that CREATEs successfully is proof the
+ generated DDL is valid on this server version. This is the high-value core:
+ for every accepted @event_type, and for representative filter parameters,
+ the harness runs @keep_alive = 1, asserts the call raised no error, asserts
+ the named session now exists, asserts the session carries exactly the events
+ that @event_type is supposed to add (read from the authoritative catalog
+ sys.server_event_session_events, which cannot be truncated and proves SQL
+ Server actually accepted each event), then DROPs the session and asserts it
+ is gone.
+
+ * @debug = 1 prints the generated @session_sql via RAISERROR (around line
+ 1768). The harness captures that per event category and asserts it contains
+ CREATE EVENT SESSION plus the events expected for the category. (RAISERROR
+ truncates a long message near 2044 chars, so the untruncated catalog check
+ above is the authoritative one; the debug-text check is a secondary guard.)
+
+Session hygiene is non-negotiable. Every session this proc or this harness
+creates is dropped, even on assertion failure:
+
+ * sp_HumanEvents names its sessions predictably:
+ HumanEvents__ (one-shot, @keep_alive = 0)
+ keeper_HumanEvents_[_] (@keep_alive = 1)
+ The harness sweeps BOTH name patterns at start (idempotency: reap anything a
+ prior aborted run left) and in a finally block at the end.
+ * Each @keep_alive matrix case drops its own session immediately after the
+ assertions, so at most one 100MB-ring-buffer session exists at a time.
+ (Creating all of them at once demanded ~1.8 GB and drew Msg 701 -- a test
+ artifact, not a proc bug; dropping-each-first avoids it.)
+ * The short live-sample cases confirm the proc dropped its OWN session: the
+ global session count is snapshotted before and after and must be equal.
+ * Around the whole run, sys.server_event_sessions is diffed to prove zero net
+ new sessions, and the diff is printed.
+
+Usage:
+ python run_tests.py [--server SQL2022] [--password L!nt0044]
+
+Exits 1 if any assertion fails.
+"""
+
+import argparse
+import re
+import subprocess
+import sys
+
+
+# ---------------------------------------------------------------- error scanning
+
+def find_sql_errors(text):
+ """Return any SQL errors of severity 16 or higher found in text.
+
+ go-sqlcmd reports errors on stdout, so callers pass BOTH streams. Matching
+ the severity numerically catches Level 16 through 19 rather than only the
+ literal "Level 16". Note: sp_HumanEvents reports user-input problems with
+ RAISERROR severity 11 (invalid @event_type, missing blocked process report,
+ etc.); those are intentionally NOT matched here -- they are asserted on by
+ text where relevant. A malformed CREATE EVENT SESSION shows up as a failed
+ create (session absent) rather than only as a severe Msg, so the presence
+ checks below are the real gate.
+ """
+ if not text:
+ return []
+ return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text)
+
+
+# ---------------------------------------------------------------- sqlcmd plumbing
+
+def _sqlcmd(server, password, sql, database="master",
+ query_timeout=None, subprocess_timeout=120):
+ """Run a batch and return (stdout, stderr) decoded as UTF-8.
+
+ Capturing bytes and decoding UTF-8 keeps any non-ASCII output from being
+ mangled by the Windows console code page. -h -1 drops result-set headers so
+ the RESULT| marker lines the batches emit are the only structured output.
+ query_timeout maps to sqlcmd -t (a query timeout that sends an attention and
+ cancels the batch server-side -- used to stop the never-ending collector
+ loop); subprocess_timeout is a hard backstop on the whole process.
+ """
+ cmd = [
+ "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ "-d", database,
+ "-W", # trim trailing spaces
+ "-h", "-1", # no result-set headers
+ "-y", "0", # unlimited variable column width (no truncation)
+ "-w", "65535", # do not wrap wide rows
+ ]
+ if query_timeout is not None:
+ cmd += ["-t", str(query_timeout)]
+ cmd += ["-Q", sql]
+ try:
+ r = subprocess.run(cmd, capture_output=True, timeout=subprocess_timeout)
+ except subprocess.TimeoutExpired as e:
+ out = (e.stdout or b"").decode("utf-8", errors="replace")
+ err = (e.stderr or b"").decode("utf-8", errors="replace")
+ return out, err + "\n[subprocess timeout]"
+ out = (r.stdout or b"").decode("utf-8", errors="replace")
+ err = (r.stderr or b"").decode("utf-8", errors="replace")
+ return out, err
+
+
+def _esc(s):
+ """Escape a T-SQL single-quoted literal."""
+ return str(s).replace("'", "''")
+
+
+# ---------------------------------------------------------------- RESULT parsing
+
+def parse_results(out):
+ """Collect the RESULT| marker lines a batch emits into a dict."""
+ d = {
+ "events": set(), "proc_ok": False, "proc_error": None,
+ "exists": None, "dropped": None, "gone": None,
+ "before": None, "after": None, "tables": set(), "views": set(),
+ "caps": {},
+ }
+ for line in out.splitlines():
+ line = line.strip()
+ if not line.startswith("RESULT|"):
+ continue
+ parts = line.split("|", 2)
+ key = parts[1] if len(parts) > 1 else ""
+ val = parts[2] if len(parts) > 2 else ""
+ if key == "event":
+ d["events"].add(val)
+ elif key == "table":
+ d["tables"].add(val)
+ elif key == "view":
+ d["views"].add(val)
+ elif key == "proc_ok":
+ d["proc_ok"] = (val == "1")
+ elif key == "proc_error":
+ d["proc_error"] = val
+ elif key == "exists":
+ d["exists"] = (val == "1")
+ elif key == "dropped":
+ d["dropped"] = (val == "1")
+ elif key == "gone":
+ d["gone"] = (val == "1")
+ elif key in ("before", "after"):
+ try:
+ d[key] = int(val)
+ except ValueError:
+ d[key] = None
+ elif key == "cap":
+ sub = val.split("|", 1)
+ if len(sub) == 2:
+ d["caps"][sub[0]] = sub[1]
+ return d
+
+
+# ---------------------------------------------------------------- event mapping
+
+def category_of(event_type):
+ """Replicate the proc's event-type routing (the CASE around line 1650):
+ %lock% then %quer% then %wait% then %recomp% then (%comp% and not %re%).
+ Order matters; this mirrors it exactly."""
+ et = event_type.lower()
+ if "lock" in et:
+ return "blocking"
+ if "quer" in et:
+ return "query"
+ if "wait" in et:
+ return "waits"
+ if "recomp" in et:
+ return "recompiles"
+ if "comp" in et and "re" not in et:
+ return "compiles"
+ return None
+
+
+def expected_events(event_type, caps, skip_plans=False):
+ """Return the SET of event names sp_HumanEvents should attach for this
+ event_type on this server, version-aware. Verified live against
+ sys.server_event_session_events on SQL Server 2022."""
+ cat = category_of(event_type)
+ v = caps["v"]
+ ce = caps["compile_events"]
+ pe = caps["param_events"]
+ if cat == "query":
+ evs = ["module_end", "rpc_completed",
+ "sp_statement_completed", "sql_statement_completed"]
+ if not skip_plans:
+ evs.append("query_post_execution_showplan")
+ return set(evs)
+ if cat == "waits":
+ # proc keys purely on @v: wait_completed on 2014+, wait_info on 2012.
+ return set(["wait_completed"]) if v > 11 else set(["wait_info"])
+ if cat == "blocking":
+ return set(["blocked_process_report"])
+ if cat == "compiles":
+ if ce:
+ evs = ["sql_statement_post_compile"]
+ else:
+ evs = ["uncached_sql_batch_statistics", "sql_statement_recompile"]
+ if pe:
+ evs.append("query_parameterization_data")
+ return set(evs)
+ if cat == "recompiles":
+ return set(["sql_statement_post_compile"]) if ce else set(["sql_statement_recompile"])
+ return set()
+
+
+def session_name(event_type, custom_name=None):
+ n = "keeper_HumanEvents_" + event_type.lower()
+ if custom_name:
+ n += "_" + custom_name
+ return n
+
+
+def build_params(params):
+ """Turn [(name, value, kind)] into a T-SQL argument tail."""
+ s = ""
+ for (name, value, kind) in params:
+ if kind == "str":
+ s += ", %s = N'%s'" % (name, _esc(value))
+ else: # int / bit
+ s += ", %s = %s" % (name, value)
+ return s
+
+
+# ---------------------------------------------------------------- session sweeps
+
+SWEEP_PREDICATE = (
+ "s.name LIKE N'HumanEvents[_]%' OR s.name LIKE N'keeper[_]HumanEvents[_]%'"
+)
+
+
+def list_he_sessions(server, password):
+ """Return the sorted list of sp_HumanEvents-owned session names present."""
+ sql = (
+ "SET NOCOUNT ON; SELECT line = 'RESULT|event|' + s.name "
+ "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";"
+ )
+ out, _ = _sqlcmd(server, password, sql)
+ return sorted(parse_results(out)["events"])
+
+
+def sweep_he_sessions(server, password):
+ """Drop every sp_HumanEvents-owned session. These names are created only by
+ sp_HumanEvents (naming contract documented in the proc's own cleanup), so
+ sweeping them is safe and makes the suite idempotent."""
+ sql = (
+ "SET NOCOUNT ON; DECLARE @drop nvarchar(max) = N''; "
+ "SELECT @drop += N'DROP EVENT SESSION ' + s.name + N' ON SERVER;' + NCHAR(10) "
+ "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + "; "
+ "IF LEN(@drop) > 0 EXECUTE sys.sp_executesql @drop;"
+ )
+ return _sqlcmd(server, password, sql)
+
+
+# ---------------------------------------------------------------- batch builders
+
+def keepalive_batch(event_type, sname, params_sql):
+ """Create a keep_alive session, report existence + its events, drop it,
+ report gone -- all as RESULT| marker lines."""
+ exists_expr = (
+ "CASE WHEN EXISTS (SELECT 1 FROM sys.server_event_sessions AS s "
+ "WHERE s.name = N'" + sname + "') THEN 1 ELSE 0 END"
+ )
+ return (
+ "SET NOCOUNT ON;\n"
+ "BEGIN TRY\n"
+ " EXECUTE dbo.sp_HumanEvents @event_type = N'" + event_type + "', "
+ "@keep_alive = 1" + params_sql + ";\n"
+ " SELECT line = 'RESULT|proc_ok|1';\n"
+ "END TRY\n"
+ "BEGIN CATCH\n"
+ " SELECT line = 'RESULT|proc_error|' + "
+ "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n"
+ "END CATCH;\n"
+ "SELECT line = 'RESULT|exists|' + CONVERT(varchar(2), " + exists_expr + ");\n"
+ "SELECT line = 'RESULT|event|' + sese.name "
+ "FROM sys.server_event_sessions AS ses "
+ "JOIN sys.server_event_session_events AS sese "
+ "ON sese.event_session_id = ses.event_session_id "
+ "WHERE ses.name = N'" + sname + "';\n"
+ "IF EXISTS (SELECT 1 FROM sys.server_event_sessions AS s WHERE s.name = N'" + sname + "')\n"
+ "BEGIN\n"
+ " BEGIN TRY EXECUTE (N'DROP EVENT SESSION " + sname + " ON SERVER;'); "
+ "SELECT line = 'RESULT|dropped|1'; END TRY\n"
+ " BEGIN CATCH SELECT line = 'RESULT|dropped|0'; END CATCH;\n"
+ "END;\n"
+ "SELECT line = 'RESULT|gone|' + CONVERT(varchar(2), "
+ "CASE WHEN EXISTS (SELECT 1 FROM sys.server_event_sessions AS s "
+ "WHERE s.name = N'" + sname + "') THEN 0 ELSE 1 END);\n"
+ )
+
+
+# ---------------------------------------------------------------- results object
+
+class Results:
+ def __init__(self):
+ self.items = []
+
+ def check(self, group, name, condition, detail=""):
+ self.items.append({
+ "group": group,
+ "name": name,
+ "passed": bool(condition),
+ "detail": detail,
+ })
+
+ @property
+ def passed(self):
+ return sum(1 for r in self.items if r["passed"])
+
+ @property
+ def failed(self):
+ return sum(1 for r in self.items if not r["passed"])
+
+
+# ---------------------------------------------------------------- capability probe
+
+def detect_caps(server, password):
+ """Read version and XE capabilities so the expected-event sets are correct
+ for whatever version we are pointed at."""
+ sql = (
+ "SET NOCOUNT ON;\n"
+ "SELECT line = 'RESULT|cap|installed|' + CASE WHEN OBJECT_ID(N'dbo.sp_HumanEvents', N'P') IS NOT NULL THEN '1' ELSE '0' END;\n"
+ "SELECT line = 'RESULT|cap|v|' + CONVERT(varchar(11), CONVERT(integer, PARSENAME(CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')), 4)));\n"
+ "SELECT line = 'RESULT|cap|mv|' + CONVERT(varchar(11), CONVERT(integer, PARSENAME(CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')), 2)));\n"
+ "SELECT line = 'RESULT|cap|azure|' + CASE WHEN CONVERT(integer, SERVERPROPERTY('EngineEdition')) = 5 THEN '1' ELSE '0' END;\n"
+ "SELECT line = 'RESULT|cap|compile_events|' + CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects AS o WHERE o.name = N'sql_statement_post_compile') THEN '1' ELSE '0' END;\n"
+ "SELECT line = 'RESULT|cap|param_events|' + CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects AS o WHERE o.name = N'query_parameterization_data') THEN '1' ELSE '0' END;\n"
+ "SELECT line = 'RESULT|cap|bpr|' + CONVERT(varchar(11), ISNULL((SELECT CONVERT(integer, c.value_in_use) FROM sys.configurations AS c WHERE c.name = N'blocked process threshold (s)'), -1));\n"
+ )
+ out, err = _sqlcmd(server, password, sql)
+ raw = parse_results(out)["caps"]
+ return {
+ "installed": raw.get("installed") == "1",
+ "v": int(raw.get("v", "0") or "0"),
+ "mv": int(raw.get("mv", "0") or "0"),
+ "azure": raw.get("azure") == "1",
+ "compile_events": raw.get("compile_events") == "1",
+ "param_events": raw.get("param_events") == "1",
+ "bpr": int(raw.get("bpr", "-1") or "-1"),
+ }, (out, err)
+
+
+# ---------------------------------------------------------------- config helpers
+
+def get_config(server, password, name):
+ sql = (
+ "SET NOCOUNT ON; SELECT line = 'RESULT|before|' + "
+ "CONVERT(varchar(11), CONVERT(integer, c.value_in_use)) "
+ "FROM sys.configurations AS c WHERE c.name = N'" + _esc(name) + "';"
+ )
+ out, _ = _sqlcmd(server, password, sql)
+ return parse_results(out)["before"]
+
+
+def set_config(server, password, name, value):
+ sql = (
+ "SET NOCOUNT ON; EXECUTE sys.sp_configure '" + _esc(name) + "', "
+ + str(value) + "; RECONFIGURE;"
+ )
+ return _sqlcmd(server, password, sql)
+
+
+# ---------------------------------------------------------------- test groups
+
+def matrix_case(server, password, R, group, label, event_type,
+ params, caps, skip_plans=False, custom_name=None):
+ sname = session_name(event_type, custom_name)
+ params_sql = build_params(params)
+ if skip_plans:
+ params_sql += ", @skip_plans = 1"
+ if custom_name:
+ params_sql += ", @custom_name = N'" + _esc(custom_name) + "'"
+ out, err = _sqlcmd(server, password, keepalive_batch(event_type, sname, params_sql))
+ combined = out + "\n" + err
+ d = parse_results(out)
+ exp = expected_events(event_type, caps, skip_plans)
+
+ R.check(group, "%s: no severe SQL error" % label,
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check(group, "%s: proc raised no error" % label,
+ d["proc_ok"] and not d["proc_error"],
+ "proc_error=%s" % d["proc_error"])
+ R.check(group, "%s: session '%s' created" % (label, sname),
+ d["exists"] is True, "exists=%s" % d["exists"])
+ R.check(group, "%s: events == %s" % (label, sorted(exp)),
+ d["events"] == exp,
+ "got %s" % sorted(d["events"]))
+ R.check(group, "%s: session dropped and gone" % label,
+ d["gone"] is True, "gone=%s dropped=%s" % (d["gone"], d["dropped"]))
+ # Belt-and-suspenders: nothing left behind by THIS case.
+ return sname
+
+
+def group_matrix_base(server, password, R, caps):
+ all_types = [
+ "waits", "blocking", "locking", "queries", "compiles", "recompiles",
+ "wait", "block", "blocks", "lock", "locks", "query",
+ "compile", "recompile", "compilation", "recompilation",
+ "compilations", "recompilations",
+ ]
+ for et in all_types:
+ matrix_case(server, password, R, "Matrix-Base", et, et, [], caps)
+
+
+def group_matrix_filters(server, password, R, caps):
+ cases = [
+ ("query+duration+db", "query",
+ [("@query_duration_ms", 1000, "int"), ("@database_name", "master", "str")], {}),
+ ("query+memory+app+host+user", "query",
+ [("@requested_memory_mb", 512, "int"),
+ ("@client_app_name", "HE_TestApp", "str"),
+ ("@client_hostname", "HE_TestHost", "str"),
+ ("@username", "HE_TestUser", "str")], {}),
+ ("query+session_sample", "query",
+ [("@session_id", "sample", "str"), ("@sample_divisor", 3, "int")], {}),
+ ("query+object", "query",
+ [("@object_name", "spt_values", "str")], {}),
+ ("query+skip_plans", "query", [], {"skip_plans": True}),
+ ("query+custom_name", "query", [], {"custom_name": "unittest"}),
+ ("waits+types+duration", "waits",
+ [("@wait_type", "SOS_SCHEDULER_YIELD,CXPACKET", "str"),
+ ("@wait_duration_ms", 5, "int")], {}),
+ ("waits+all+danger", "waits",
+ [("@wait_type", "ALL", "str"), ("@gimme_danger", 1, "bit")], {}),
+ ("blocking+duration", "blocking",
+ [("@blocking_duration_ms", 5000, "int")], {}),
+ ("blocking+object", "blocking",
+ [("@database_name", "master", "str"),
+ ("@object_name", "spt_values", "str"),
+ ("@object_schema", "dbo", "str"),
+ ("@blocking_duration_ms", 1000, "int")], {}),
+ ("compiles+app", "compiles",
+ [("@client_app_name", "HE_TestApp", "str")], {}),
+ ("recompiles+db", "recompiles",
+ [("@database_name", "master", "str")], {}),
+ ]
+ for (label, et, params, opts) in cases:
+ matrix_case(server, password, R, "Matrix-Filter", label, et, params, caps,
+ skip_plans=opts.get("skip_plans", False),
+ custom_name=opts.get("custom_name"))
+
+
+def group_debug_ddl(server, password, R, caps):
+ """Capture the @debug = 1 generated @session_sql per event category and
+ assert it contains CREATE EVENT SESSION and each expected event."""
+ reps = ["query", "waits", "blocking", "compiles", "recompiles"]
+ first = True
+ for et in reps:
+ sname = session_name(et)
+ batch = (
+ "SET NOCOUNT ON;\n"
+ "EXECUTE dbo.sp_HumanEvents @event_type = N'" + et + "', "
+ "@keep_alive = 1, @debug = 1;\n"
+ "IF EXISTS (SELECT 1 FROM sys.server_event_sessions AS s WHERE s.name = N'" + sname + "') "
+ "EXECUTE (N'DROP EVENT SESSION " + sname + " ON SERVER;');\n"
+ )
+ out, err = _sqlcmd(server, password, batch)
+ combined = out + "\n" + err
+ exp = expected_events(et, caps)
+ R.check("DebugDDL", "%s: no severe SQL error" % et,
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("DebugDDL", "%s: debug prints CREATE EVENT SESSION for %s" % (et, sname),
+ ("CREATE EVENT SESSION" in combined) and (sname in combined),
+ "CREATE EVENT SESSION or session name not found in debug output")
+ for ev in sorted(exp):
+ R.check("DebugDDL", "%s: debug DDL names event %s" % (et, ev),
+ ev in combined, "event %s not present in debug text" % ev)
+ if first:
+ R.check("DebugDDL", "%s: @debug emits diagnostic markers" % et,
+ "Setting up the event session" in combined,
+ "expected diagnostic marker not found")
+ first = False
+
+
+def group_smoke(server, password, R, caps):
+ # ---- @help = 1 -------------------------------------------------------
+ help_batch = (
+ "SET NOCOUNT ON;\n"
+ "EXECUTE dbo.sp_HumanEvents @help = 1;\n"
+ "SELECT line = 'RESULT|after|' + CONVERT(varchar(11), COUNT_BIG(*)) "
+ "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";\n"
+ )
+ out, err = _sqlcmd(server, password, help_batch)
+ combined = out + "\n" + err
+ d = parse_results(out)
+ R.check("Smoke", "@help = 1: no severe SQL error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("Smoke", "@help = 1: returns help text",
+ "allow me to reintroduce myself" in combined,
+ "help introduction text not found")
+ R.check("Smoke", "@help = 1: creates no session",
+ d["after"] == 0, "session count after @help = %s" % d["after"])
+
+ # ---- invalid @event_type --------------------------------------------
+ bad_batch = (
+ "SET NOCOUNT ON;\n"
+ "BEGIN TRY\n"
+ " EXECUTE dbo.sp_HumanEvents @event_type = N'garbage', @keep_alive = 1;\n"
+ " SELECT line = 'RESULT|proc_ok|1';\n"
+ "END TRY\n"
+ "BEGIN CATCH\n"
+ " SELECT line = 'RESULT|proc_error|' + "
+ "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n"
+ "END CATCH;\n"
+ "SELECT line = 'RESULT|after|' + CONVERT(varchar(11), COUNT_BIG(*)) "
+ "FROM sys.server_event_sessions AS s WHERE s.name = N'keeper_HumanEvents_garbage';\n"
+ )
+ out, err = _sqlcmd(server, password, bad_batch)
+ combined = out + "\n" + err
+ d = parse_results(out)
+ R.check("Smoke", "invalid @event_type: no severe SQL error (rejected at sev 11)",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("Smoke", "invalid @event_type: rejected with the validation message",
+ "What on earth" in combined,
+ "expected @event_type validation message not found")
+ R.check("Smoke", "invalid @event_type: no session created",
+ d["after"] == 0, "a keeper_HumanEvents_garbage session exists")
+
+
+def group_live_sample(server, password, R, caps):
+ """The full create/sample/query/drop path with a small @seconds_sample.
+ Empty capture is expected and must NOT error, and the proc must drop its
+ own throwaway session (global session count before == after)."""
+ cases = [
+ ("waits", [("@wait_duration_ms", 1, "int")]),
+ ("query", [("@query_duration_ms", 0, "int")]),
+ ]
+ for (et, params) in cases:
+ batch = (
+ "SET NOCOUNT ON;\n"
+ "SELECT line = 'RESULT|before|' + CONVERT(varchar(11), COUNT_BIG(*)) "
+ "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";\n"
+ "BEGIN TRY\n"
+ " EXECUTE dbo.sp_HumanEvents @event_type = N'" + et + "', "
+ "@seconds_sample = 3" + build_params(params) + ";\n"
+ " SELECT line = 'RESULT|proc_ok|1';\n"
+ "END TRY\n"
+ "BEGIN CATCH\n"
+ " SELECT line = 'RESULT|proc_error|' + "
+ "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n"
+ "END CATCH;\n"
+ "SELECT line = 'RESULT|after|' + CONVERT(varchar(11), COUNT_BIG(*)) "
+ "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";\n"
+ )
+ out, err = _sqlcmd(server, password, batch, subprocess_timeout=60)
+ combined = out + "\n" + err
+ d = parse_results(out)
+ R.check("LiveSample", "%s 3s sample: no severe SQL error" % et,
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("LiveSample", "%s 3s sample: proc completed (empty capture OK)" % et,
+ d["proc_ok"] and not d["proc_error"],
+ "proc_error=%s" % d["proc_error"])
+ R.check("LiveSample", "%s 3s sample: proc dropped its own session (net 0)" % et,
+ d["before"] is not None and d["after"] == d["before"],
+ "before=%s after=%s" % (d["before"], d["after"]))
+
+
+def group_collector(server, password, R, caps):
+ """Logging-to-table mode: @keep_alive = 1 with @output_database_name enters
+ an unbounded collector loop that creates permanent tables/views for keeper
+ sessions and harvests into them forever. The tables are created in the first
+ pass (well under a second), so the harness runs it with a query timeout
+ (-t) that sends an attention and cleanly cancels the loop server-side, then
+ asserts the logging objects were created. It then exercises the proc's own
+ @cleanup = 1 teardown path and asserts the proc removed the session, tables,
+ and views itself. Everything is created in and dropped with a throwaway
+ scratch database.
+
+ (This group runs last, after every matrix case has dropped its own session,
+ so no other keeper_HumanEvents_ session exists when @cleanup = 1 -- which
+ sweeps keeper sessions server-wide -- is invoked.)"""
+ db = "sp_HumanEvents_test_scratch"
+ sname = "keeper_HumanEvents_waits"
+ drop_db = (
+ "IF DB_ID('" + db + "') IS NOT NULL BEGIN "
+ "ALTER DATABASE [" + db + "] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; "
+ "DROP DATABASE [" + db + "]; END;"
+ )
+ # Idempotent setup.
+ out, err = _sqlcmd(server, password,
+ "SET NOCOUNT ON; " + drop_db + " CREATE DATABASE [" + db + "];")
+ setup_err = find_sql_errors(out) + find_sql_errors(err)
+ R.check("Collector", "setup: scratch database created", not setup_err, str(setup_err))
+ if setup_err:
+ _sqlcmd(server, password, "SET NOCOUNT ON; " + drop_db)
+ return
+
+ want_views = {"HumanEvents_WaitsByDatabase",
+ "HumanEvents_WaitsByQueryAndDatabase",
+ "HumanEvents_WaitsTotal"}
+ obj_chk = (
+ "SET NOCOUNT ON;\n"
+ "SELECT line = 'RESULT|table|' + t.name FROM sys.tables AS t;\n"
+ "SELECT line = 'RESULT|view|' + v.name FROM sys.views AS v;\n"
+ )
+
+ try:
+ collector = (
+ "SET NOCOUNT ON; EXECUTE dbo.sp_HumanEvents "
+ "@event_type = N'waits', @keep_alive = 1, @wait_duration_ms = 1, "
+ "@output_database_name = N'" + db + "', @output_schema_name = N'dbo';"
+ )
+ # -t 15: the loop never returns on its own; time it out. Table creation
+ # happens in the first pass, long before this fires.
+ out, err = _sqlcmd(server, password, collector,
+ query_timeout=15, subprocess_timeout=60)
+ combined = out + "\n" + err
+ # The timeout itself is not a severe Msg; only real errors count.
+ R.check("Collector", "collector run: no severe SQL error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+
+ out2, err2 = _sqlcmd(server, password, obj_chk, database=db)
+ d = parse_results(out2)
+ R.check("Collector", "collector created base table keeper_HumanEvents_waits",
+ "keeper_HumanEvents_waits" in d["tables"],
+ "tables=%s" % sorted(d["tables"]))
+ R.check("Collector", "collector created waits views (%s)" % sorted(want_views),
+ want_views <= d["views"], "views=%s" % sorted(d["views"]))
+
+ # ----- exercise the proc's own @cleanup = 1 teardown ------------
+ cleanup = (
+ "SET NOCOUNT ON;\n"
+ "BEGIN TRY\n"
+ " EXECUTE dbo.sp_HumanEvents @event_type = N'waits', @cleanup = 1, "
+ "@output_database_name = N'" + db + "', @output_schema_name = N'dbo';\n"
+ " SELECT line = 'RESULT|proc_ok|1';\n"
+ "END TRY\n"
+ "BEGIN CATCH\n"
+ " SELECT line = 'RESULT|proc_error|' + "
+ "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n"
+ "END CATCH;\n"
+ )
+ outc, errc = _sqlcmd(server, password, cleanup)
+ dc = parse_results(outc)
+ R.check("Collector", "@cleanup = 1: no severe SQL error",
+ not find_sql_errors(outc + "\n" + errc),
+ str(find_sql_errors(outc + "\n" + errc)))
+ R.check("Collector", "@cleanup = 1: proc raised no error",
+ dc["proc_ok"] and not dc["proc_error"],
+ "proc_error=%s" % dc["proc_error"])
+
+ out3, _ = _sqlcmd(server, password, obj_chk, database=db)
+ d3 = parse_results(out3)
+ R.check("Collector", "@cleanup = 1 removed the logging tables",
+ "keeper_HumanEvents_waits" not in d3["tables"],
+ "tables still present: %s" % sorted(d3["tables"]))
+ R.check("Collector", "@cleanup = 1 removed the logging views",
+ not (want_views & d3["views"]),
+ "views still present: %s" % sorted(d3["views"] & want_views))
+ out4, _ = _sqlcmd(server, password,
+ "SET NOCOUNT ON; SELECT line = 'RESULT|gone|' + CONVERT(varchar(2), "
+ "CASE WHEN EXISTS (SELECT 1 FROM sys.server_event_sessions AS s "
+ "WHERE s.name = N'" + sname + "') THEN 0 ELSE 1 END);")
+ R.check("Collector", "@cleanup = 1 dropped the keeper session",
+ parse_results(out4)["gone"] is True,
+ "keeper session still present after @cleanup")
+ finally:
+ # Safety net: drop any residual session and the scratch database even if
+ # the collector or the @cleanup path failed partway through.
+ _sqlcmd(server, password,
+ "SET NOCOUNT ON; "
+ "IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'" + sname + "') "
+ "DROP EVENT SESSION " + sname + " ON SERVER; " + drop_db)
+ out, _ = _sqlcmd(server, password,
+ "SET NOCOUNT ON; SELECT line = 'RESULT|after|' + CONVERT(varchar(11), "
+ "(SELECT COUNT_BIG(*) FROM sys.databases WHERE name = N'" + db + "') + "
+ "(SELECT COUNT_BIG(*) FROM sys.server_event_sessions WHERE name = N'" + sname + "'));")
+ left = parse_results(out)["after"]
+ R.check("Collector", "cleanup: scratch database and keeper session dropped",
+ left == 0, "residual objects = %s" % left)
+
+
+# ---------------------------------------------------------------- main
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--server", default="SQL2022")
+ ap.add_argument("--password", default="L!nt0044")
+ args = ap.parse_args()
+
+ try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ except Exception:
+ pass
+
+ print("Running sp_HumanEvents DDL-validity tests against %s..." % args.server)
+ print()
+
+ caps, _raw = detect_caps(args.server, args.password)
+ if not caps["installed"]:
+ print("ERROR: dbo.sp_HumanEvents is not installed in master on %s." % args.server)
+ print("Install sp_HumanEvents.sql before running this harness.")
+ sys.exit(1)
+ if caps["azure"]:
+ print("ERROR: this harness targets a box-product instance "
+ "(server-scoped sessions); the target reports Azure SQL DB.")
+ sys.exit(1)
+
+ print("Server: v=%d mv=%d compile_events=%s param_events=%s bpr=%d"
+ % (caps["v"], caps["mv"], caps["compile_events"],
+ caps["param_events"], caps["bpr"]))
+
+ R = Results()
+
+ # ----- session hygiene: idempotent start ------------------------------
+ sweep_he_sessions(args.server, args.password)
+ before = list_he_sessions(args.server, args.password)
+ print("Sessions before run (post-sweep): %s" % (before if before else "(none)"))
+ print()
+
+ # ----- blocked process report: blocking needs threshold > 0 ----------
+ touched_bpr = False
+ orig_show = None
+ if caps["bpr"] == 0:
+ orig_show = get_config(args.server, args.password, "show advanced options")
+ set_config(args.server, args.password, "show advanced options", 1)
+ set_config(args.server, args.password, "blocked process threshold (s)", 5)
+ touched_bpr = True
+ print("blocked process threshold was 0; temporarily set to 5 for the "
+ "blocking cases (will be restored).")
+ print()
+
+ try:
+ group_smoke(args.server, args.password, R, caps)
+ group_matrix_base(args.server, args.password, R, caps)
+ group_matrix_filters(args.server, args.password, R, caps)
+ group_debug_ddl(args.server, args.password, R, caps)
+ group_live_sample(args.server, args.password, R, caps)
+ group_collector(args.server, args.password, R, caps)
+ finally:
+ # ----- session hygiene: measure leaks, then sweep ----------------
+ after_tests = list_he_sessions(args.server, args.password)
+ sweep_he_sessions(args.server, args.password)
+ after_final = list_he_sessions(args.server, args.password)
+
+ # ----- restore blocked process report ----------------------------
+ if touched_bpr:
+ set_config(args.server, args.password, "blocked process threshold (s)", 0)
+ set_config(args.server, args.password, "show advanced options",
+ orig_show if orig_show is not None else 0)
+ now_bpr = get_config(args.server, args.password, "blocked process threshold (s)")
+ R.check("Hygiene", "blocked process threshold restored to 0",
+ now_bpr == 0, "bpr now %s" % now_bpr)
+
+ R.check("Hygiene", "zero net Extended Events sessions after the run",
+ after_tests == before,
+ "leaked: %s" % [s for s in after_tests if s not in before])
+ R.check("Hygiene", "no sp_HumanEvents sessions remain after sweep",
+ after_final == before,
+ "still present: %s" % after_final)
+
+ print()
+ print("Sessions after tests (pre-final-sweep): %s"
+ % (after_tests if after_tests else "(none)"))
+ print("Sessions after final sweep: %s"
+ % (after_final if after_final else "(none)"))
+ net_new = [s for s in after_tests if s not in before]
+ print("Net-new sessions (diff): %s"
+ % (net_new if net_new else "(none) -> zero net"))
+ print()
+
+ for r in R.items:
+ status = "PASS" if r["passed"] else "FAIL"
+ detail = (" (%s)" % r["detail"]) if (not r["passed"] and r["detail"]) else ""
+ print(" [%s] %s: %s%s" % (status, r["group"], r["name"], detail))
+
+ print()
+ print("Results: %d passed, %d failed, %d total" % (R.passed, R.failed, len(R.items)))
+
+ if R.failed > 0:
+ print()
+ print("FAILED TESTS:")
+ for r in R.items:
+ if not r["passed"]:
+ print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"]))
+ sys.exit(1)
+ else:
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
From efd892f2bf3c7755f3b0bfe2ab805458a0e8d67e Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 18:48:50 -0400
Subject: [PATCH 26/60] sp_PerfCheck: tolerate NULL
is_accelerated_database_recovery_on (inaccessible dbs)
The #databases collection has no state filter - it inspects every user database
regardless of accessibility. A database that is closed (AUTO_CLOSE) or OFFLINE
returns NULL for is_accelerated_database_recovery_on in sys.databases, because
ADR state lives with the database rather than in master metadata and cannot be
read while the database is shut. That column was NOT NULL in #databases, so the
collection INSERT rejected the NULL with Msg 515 and aborted the entire check.
Because there is no state filter, a single inaccessible database could take down
a whole-instance run.
Made the column nullable, matching its already-nullable siblings (collation_name,
target_recovery_time_in_seconds, delayed_durability_desc, is_ledger_on - all of
which sys.databases also NULLs out for a closed database). NULL flows through
harmlessly: the only finding that reads the column tests "= 0", which NULL never
satisfies, so a database that cannot be assessed simply gets no ADR
recommendation - the correct outcome.
Surfaced on SQL Server 2025 by the sp_PerfCheck test harness, but the mechanism
is version-independent (the column exists from 2019 on and is version-guarded in
the collection).
Added an Inaccessible regression group to tests/run_tests.py: it closes and then
offlines a scratch database and asserts scoped runs and a whole-instance run all
complete without Msg 515. Proven bidirectionally - the group fails against the
pre-fix procedure (Msg 515 at the #databases insert) and passes against this one.
Harness is now 53 assertions.
Verified on 2016/2017/2019/2022/2025: compiles clean; the reproduction (scoped and
whole-instance runs against a closed/offline database) no longer raises Msg 515;
full harness 53/53; scratch database cleaned up.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/sp_PerfCheck.sql | 12 ++++-
sp_PerfCheck/tests/README.md | 28 ++++++------
sp_PerfCheck/tests/run_tests.py | 77 ++++++++++++++++++++++++++++++---
3 files changed, 96 insertions(+), 21 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index 1d0a70f1..1a82e61f 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -467,7 +467,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
is_cdc_enabled bit NOT NULL,
target_recovery_time_in_seconds integer NULL,
delayed_durability_desc nvarchar(60) NULL,
- is_accelerated_database_recovery_on bit NOT NULL,
+ /*
+ Nullable on purpose. A database that is closed (AUTO_CLOSE), OFFLINE, or
+ otherwise inaccessible returns NULL for is_accelerated_database_recovery_on
+ in sys.databases - ADR state lives with the database, not in master
+ metadata, so it can't be read while the database is shut. The collection
+ below has no state filter, so a single inaccessible database would
+ otherwise fail this INSERT with Msg 515 and abort the whole check. NULL
+ flows through harmlessly: the ADR finding tests "= 0", which NULL never
+ satisfies, so a database we cannot assess simply gets no recommendation.
+ */
+ is_accelerated_database_recovery_on bit NULL,
is_ledger_on bit NULL
);
diff --git a/sp_PerfCheck/tests/README.md b/sp_PerfCheck/tests/README.md
index c6dec1fa..ec6d3c80 100644
--- a/sp_PerfCheck/tests/README.md
+++ b/sp_PerfCheck/tests/README.md
@@ -160,21 +160,23 @@ Statistics) to exercise the per-database cursor path and the `database_name`
scoping without turning the suite into an exhaustive per-setting matrix. See the
Auto-Close note below for why that specific one was dropped.
-### A real fragility this harness surfaced: AUTO_CLOSE on SQL Server 2025
+### A real fragility this harness surfaced, now fixed and asserted
The database-scoped group was originally written to force **AUTO_CLOSE** as its
-second toggle. On **SQL Server 2025**, forcing `AUTO_CLOSE ON` and then scoping
-`sp_PerfCheck` to that (now closed) database makes `sys.databases` return `NULL`
-for `collation_name`, `target_recovery_time_in_seconds`, and
-`delayed_durability_desc`, which the procedure's `#databases` insert rejects with
-`Msg 515` (`Cannot insert the value NULL`). The database is never analyzed and
-the run aborts. On 2016-2022 the same test passed, so this is a genuine,
-version-dependent fragility in `sp_PerfCheck` itself, not a test artifact.
-
-Rather than assert around a procedure error (which would make the suite flake by
-version) or edit the procedure, the harness **switched its second toggle to
-AUTO_UPDATE_STATISTICS**, which keeps the database open and behaves identically
-on every version. The AUTO_CLOSE behavior is documented here so it is not lost.
+second toggle, and it uncovered a crash: a closed (`AUTO_CLOSE`) or `OFFLINE`
+database returns `NULL` for `is_accelerated_database_recovery_on` in
+`sys.databases` -- ADR state lives with the database, not master metadata, so it
+cannot be read while the database is shut -- and that column was `NOT NULL` in the
+procedure's `#databases` table, so the collection `INSERT` rejected the `NULL`
+with `Msg 515` and aborted the whole run. Because the collection has no state
+filter, a single inaccessible database could take down a full-instance run. It
+showed up on SQL Server 2025 first, but the mechanism is version-independent.
+
+The column is now nullable, and the `Inaccessible` group asserts the fix stays in:
+it closes and then offlines a scratch database and confirms scoped runs and a
+whole-instance run all complete without `Msg 515`. The database-scoped toggle
+group uses `AUTO_UPDATE_STATISTICS` instead of `AUTO_CLOSE` for a separate reason
+-- it keeps the database open, so the finding it forces is actually assessable.
## Not wired into CI
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
index f5760b72..12892ad5 100644
--- a/sp_PerfCheck/tests/run_tests.py
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -398,13 +398,12 @@ def database_scoped_tests(server, password, R):
DATABASE settings, so a scratch database can force each on and off
deterministically, and both leave the database OPEN.
- (AUTO_CLOSE was deliberately NOT used. Forcing AUTO_CLOSE on and scoping the
- procedure to the closed database makes sys.databases return NULL for
- collation_name / target_recovery_time_in_seconds / delayed_durability_desc
- on SQL Server 2025, which sp_PerfCheck's #databases insert rejects with
- Msg 515. That is a real, version-dependent fragility in the procedure, not a
- test artifact; it is documented in README.md rather than asserted, so this
- harness stays deterministic across versions.)
+ (AUTO_CLOSE is deliberately NOT used for the ON/OFF finding toggles here,
+ because closing the database changes which findings are even assessable. The
+ crash it used to cause -- a closed or offline database made sys.databases
+ return NULL for is_accelerated_database_recovery_on, which the #databases
+ insert rejected with Msg 515 -- is now fixed, and inaccessible_database_tests
+ below asserts it stays fixed.)
The design keeps every absence assertion non-vacuous WITHOUT relying on any
volatile finding: in each run one toggle is set to its finding-producing
@@ -494,6 +493,69 @@ def scoped_run():
not DB_marker(server, password, db), "scratch database still exists")
+def inaccessible_database_tests(server, password, R):
+ """Regression test for a Msg 515 crash on inaccessible databases.
+
+ sp_PerfCheck's #databases collection has no state filter, so it inspects
+ every user database regardless of accessibility. A database that is closed
+ (AUTO_CLOSE) or OFFLINE returns NULL for is_accelerated_database_recovery_on
+ in sys.databases -- ADR state lives with the database, not master metadata,
+ so it cannot be read while the database is shut. That column used to be
+ NOT NULL in #databases, so the collection INSERT rejected the NULL with
+ Msg 515 and aborted the entire check. One inaccessible database could take
+ down a whole-instance run. The column is now nullable; these assertions prove
+ the procedure completes cleanly instead of crashing.
+ """
+ db = "perfcheck_test_inaccessible"
+ grp = "Inaccessible"
+
+ drop_sql = (
+ "SET NOCOUNT ON; "
+ "IF DB_ID('%s') IS NOT NULL "
+ "BEGIN "
+ "ALTER DATABASE [%s] SET ONLINE; "
+ "ALTER DATABASE [%s] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; "
+ "DROP DATABASE [%s]; "
+ "END;"
+ ) % (db, db, db, db)
+
+ out, err = _sqlcmd(server, password, drop_sql + " CREATE DATABASE [%s];" % db)
+ R.check(grp, "setup: scratch database created",
+ not (find_sql_errors(out) + find_sql_errors(err))
+ and DB_marker(server, password, db),
+ "scratch database not created")
+
+ try:
+ # AUTO_CLOSE, then force it shut with an offline/online cycle so the next
+ # metadata read sees a closed database.
+ _sqlcmd(server, password, "ALTER DATABASE [%s] SET AUTO_CLOSE ON;" % db)
+ _sqlcmd(server, password,
+ "ALTER DATABASE [%s] SET OFFLINE; "
+ "ALTER DATABASE [%s] SET ONLINE;" % (db, db))
+ out, err = run_perfcheck(server, password, "@database_name = N'%s'" % db)
+ errs = find_sql_errors(out) + find_sql_errors(err)
+ R.check(grp, "AUTO_CLOSE-closed database scoped run does not crash (Msg 515)",
+ not errs, str(errs))
+
+ # OFFLINE is the more severe case, and the one a whole-instance run hits.
+ _sqlcmd(server, password, "ALTER DATABASE [%s] SET OFFLINE;" % db)
+ out, err = run_perfcheck(server, password, "@database_name = N'%s'" % db)
+ errs = find_sql_errors(out) + find_sql_errors(err)
+ R.check(grp, "OFFLINE database scoped run does not crash (Msg 515)",
+ not errs, str(errs))
+
+ # The load-bearing case: a whole-instance run must not be aborted by one
+ # inaccessible database sitting on the server.
+ out, err = run_perfcheck(server, password)
+ errs = find_sql_errors(out) + find_sql_errors(err)
+ R.check(grp, "whole-instance run with an OFFLINE database present does not crash",
+ not errs, str(errs))
+ finally:
+ _sqlcmd(server, password, drop_sql)
+ R.check(grp, "cleanup: scratch database dropped",
+ not DB_marker(server, password, db), "scratch database still exists")
+
+
def DB_marker(server, password, db):
"""True if the named database currently exists."""
out, _ = _sqlcmd(
@@ -539,6 +601,7 @@ def main():
structural_tests(args.server, args.password, R)
forced_condition_tests(args.server, args.password, R)
database_scoped_tests(args.server, args.password, R)
+ inaccessible_database_tests(args.server, args.password, R)
for r in R.items:
status = "PASS" if r["passed"] else "FAIL"
From 1a5651d6e89ab3f7624bf77408ff6008b30b04c0 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 20:36:41 -0400
Subject: [PATCH 27/60] sp_PerfCheck: normalize storage-finding granularity
(proven template)
First cluster of a finding-granularity cleanup. The storage/I/O checks reported
the same data at three grains with the level expressed inconsistently: file-level
3001/3002 showed the file but not the drive it lives on, while drive-level 3003
baked the drive letter into the finding text and left the identity columns empty.
So a file finding and its drive rollup didn't line up, and finding text varied
per row.
Applies the convention this cleanup will roll out:
- finding is a stable, groupable label. 3003 becomes "Multiple Slow Files on
Storage Location" with no drive appended.
- the thing it is about goes in object_name. 3003 now sets object_name to the
drive; 3001/3002 prefix the drive onto the physical file name.
- the level then reads off the columns with no new field: 3001/3002 carry a
database and a file, so they read as File; 3003 carries a drive and no
database, so it reads as Drive.
object_name for a file finding is now " ()",
e.g. "C: StackOverflow2013_2.ndf (ROWS)". The physical file name differentiates
every file (logical names repeat across databases and don't say where the file
lives), stays short (no full folder path), and carries the drive so it lines up
with the 3003 rollup. Blob-storage paths, where drive_location already holds the
whole URL, are shown as-is. The finding reads from #io_stats, whose columns are
in the server default collation, so no COLLATE is needed here.
Output shape is unchanged (no new column); only the values in finding and
object_name change, and only for these three findings.
Verified on 2016/2017/2019/2022/2025: compiles clean; the existing harness stays
53/53; live output inspected with lowered thresholds shows the new finding/
object_name values, including two data files on one database differentiated by
physical file name. Storage-finding content is not deterministically forceable
through the public interface (it needs real slow I/O), so it stays in the
manually-verified bucket the README already documents; this was verified by hand.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/sp_PerfCheck.sql | 52 +++++++++++++++++++++++++++++++----
1 file changed, 47 insertions(+), 5 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index 1a82e61f..e1f21743 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -2844,7 +2844,26 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
finding = N'Slow Read Latency',
database_name = i.database_name,
object_name =
- i.file_name +
+ /*
+ Drive/volume plus the physical file name on disk. This makes a file
+ finding self-contained, tells apart every file (logical names repeat
+ across databases and don't say where the file lives), and lines up
+ with the drive-level rollup (check_id 3003), which shows the same
+ drive. For blob storage drive_location already holds the whole URL, so
+ use it as-is; otherwise take the last path segment after the final
+ separator.
+ */
+ CASE
+ WHEN i.drive_location = i.physical_name
+ THEN i.physical_name
+ ELSE
+ ISNULL(i.drive_location + N' ', N'') +
+ RIGHT
+ (
+ i.physical_name,
+ CHARINDEX(N'\', REVERSE(i.physical_name) + N'\') - 1
+ )
+ END +
N' (' +
i.type_desc +
N')',
@@ -2887,7 +2906,26 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
finding = N'Slow Write Latency',
database_name = i.database_name,
object_name =
- i.file_name +
+ /*
+ Drive/volume plus the physical file name on disk. This makes a file
+ finding self-contained, tells apart every file (logical names repeat
+ across databases and don't say where the file lives), and lines up
+ with the drive-level rollup (check_id 3003), which shows the same
+ drive. For blob storage drive_location already holds the whole URL, so
+ use it as-is; otherwise take the last path segment after the final
+ separator.
+ */
+ CASE
+ WHEN i.drive_location = i.physical_name
+ THEN i.physical_name
+ ELSE
+ ISNULL(i.drive_location + N' ', N'') +
+ RIGHT
+ (
+ i.physical_name,
+ CHARINDEX(N'\', REVERSE(i.physical_name) + N'\') - 1
+ )
+ END +
N' (' +
i.type_desc +
N')',
@@ -2913,6 +2951,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
priority,
category,
finding,
+ object_name,
details,
url
)
@@ -2920,9 +2959,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 3003,
priority = 20, /* High: systemic storage problem */
category = N'Storage Performance',
- finding =
- N'Multiple Slow Files on Storage Location ' +
- i.drive_location,
+ /*
+ Stable label; the drive/volume that varies per row goes in object_name so
+ this finding groups and the identity lives in a column, not the sentence.
+ */
+ finding = N'Multiple Slow Files on Storage Location',
+ object_name = i.drive_location,
details =
N'Storage location ' +
i.drive_location +
From 42e1a24bad30453888064ded056268740f4f463b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 17 Jul 2026 21:07:10 -0400
Subject: [PATCH 28/60] sp_PerfCheck: normalize config and database finding
granularity
Rolls the storage-cluster convention (PR #831) across the remaining findings that
baked a per-row identifier into the finding text: the finding becomes a stable,
groupable label and the identifier moves to object_name. Output shape is
unchanged - only the finding/object_name values move, no new column.
1000 Non-Default Configuration: finding is now the constant
"Non-Default Configuration" and the setting name moves to object_name.
7003 Restricted Access Mode: mode (RESTRICTED_USER/SINGLE_USER) -> object_name.
7008 Delayed Durability: the durability mode -> object_name.
6001 High Impact Wait Type: the wait type and its category -> object_name.
6003 Top Memory Consumer: the memory clerk type -> object_name.
Also dedupes 1000 against the specific checks it overlapped. Priority boost
(1005), lightweight pooling (1006), and the four affinity masks (1008-1011) each
have a dedicated check that fires on the identical non-default condition, so a
setting was reported twice. They are now excluded from the 1000 scan. MAXDOP,
cost threshold, and max/min memory are deliberately kept in 1000: their specific
checks have narrower conditions (1003 fires when MAXDOP = 0, 1000 when MAXDOP is
any other non-default value), so removing them would lose coverage rather than
just a duplicate.
And 4101/4103, which both emitted the literal "Memory-Starved Queries Detected"
and rendered as duplicate rows, now read "Memory-Starved Queries: Forced Grants"
and "Memory-Starved Queries: Grant Timeouts" - two distinct, stable per-check
labels rather than a per-row identifier.
Test harness updated to the new contract: the forced-configuration group now
matches the stable finding plus object_name = the setting name, which also locks
the convention (a regression to the baked-in "Non-Default Configuration: "
form would fail it).
Verified on 2016/2017/2019/2022/2025: compiles clean; harness 53/53; the 1000
normalize is exercised by the forced-config group; 7003 and 7008 confirmed live
against a scratch database (stable finding, mode in object_name); the 1000 dedup
is sound by construction (the six settings removed from the scan are each fully
covered by a specific check firing on the same condition). Wait-type (6001) and
memory-clerk (6003) content is volatile and not deterministically forceable, so
those were verified by reading the generated SQL.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/sp_PerfCheck.sql | 48 +++++++++++++++++++--------------
sp_PerfCheck/tests/run_tests.py | 16 ++++++-----
2 files changed, 38 insertions(+), 26 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index e1f21743..b37244df 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -974,7 +974,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 4101,
priority = 20, /* High: active memory spills */
category = N'Memory Pressure',
- finding = N'Memory-Starved Queries Detected',
+ finding = N'Memory-Starved Queries: Forced Grants',
details =
N'dm_exec_query_resource_semaphores has ' +
CONVERT(nvarchar(10), MAX(ders.forced_grant_count)) +
@@ -1001,7 +1001,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 4103,
priority = 20, /* High: queries can't get memory */
category = N'Memory Pressure',
- finding = N'Memory-Starved Queries Detected',
+ finding = N'Memory-Starved Queries: Grant Timeouts',
details =
N'dm_exec_query_resource_semaphores has ' +
CONVERT(nvarchar(10), MAX(ders.timeout_error_count)) +
@@ -2073,6 +2073,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
priority,
category,
finding,
+ object_name,
details,
url
)
@@ -2089,8 +2090,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ELSE 40 /* Low: >=10% of uptime */
END,
category = N'Wait Statistics',
- finding =
- N'High Impact Wait Type: ' +
+ finding = N'High Impact Wait Type',
+ object_name =
ws.wait_type +
N' (' +
ws.category +
@@ -2467,6 +2468,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
priority,
category,
finding,
+ object_name,
details,
url
)
@@ -2474,9 +2476,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 6003,
priority = 50, /* Informational: memory context */
category = N'Memory Usage',
- finding =
- N'Top Memory Consumer: ' +
- domc.type,
+ finding = N'Top Memory Consumer',
+ object_name = domc.type,
details =
N'Memory clerk "' +
domc.type +
@@ -3166,6 +3167,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
priority,
category,
finding,
+ object_name,
details,
url
)
@@ -3173,7 +3175,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 1000,
priority = 50, /* Informational: non-default config */
category = N'Server Configuration',
- finding = N'Non-Default Configuration: ' + c.name,
+ /*
+ Stable label; the setting name that varies per row goes in
+ object_name so this finding groups and the setting is filterable.
+ */
+ finding = N'Non-Default Configuration',
+ object_name = c.name,
details =
N'Configuration option "' + c.name +
N'" has been changed from the default. Current: ' +
@@ -3215,11 +3222,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
OR (c.name = N'ADR cleaner retry timeout (min)' AND c.value_in_use NOT IN (0, 15, 120))
OR (c.name = N'ADR Cleaner Thread Count' AND c.value_in_use <> 1)
OR (c.name = N'ADR Preallocation Factor' AND c.value_in_use NOT IN (0, 4))
- /* Affinity settings */
- OR (c.name = N'affinity mask' AND c.value_in_use <> 0)
- OR (c.name = N'affinity I/O mask' AND c.value_in_use <> 0)
- OR (c.name = N'affinity64 mask' AND c.value_in_use <> 0)
- OR (c.name = N'affinity64 I/O mask' AND c.value_in_use <> 0)
+ /*
+ Affinity masks (1008-1011), priority boost (1005), and lightweight
+ pooling (1006) have their own dedicated checks that fire on exactly
+ the same non-default condition, so they are excluded here - otherwise
+ each would be reported twice, once generically and once specifically.
+ */
/* Common performance settings */
OR (c.name = N'cost threshold for parallelism' AND c.value_in_use <> 5)
OR (c.name = N'max degree of parallelism' AND c.value_in_use <> 0)
@@ -3228,11 +3236,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
OR (c.name = N'min memory per query (KB)' AND c.value_in_use <> 1024)
OR (c.name = N'min server memory (MB)' AND c.value_in_use NOT IN (0, 16))
OR (c.name = N'optimize for ad hoc workloads' AND c.value_in_use <> 0)
- OR (c.name = N'priority boost' AND c.value_in_use <> 0)
OR (c.name = N'query governor cost limit' AND c.value_in_use <> 0)
OR (c.name = N'recovery interval (min)' AND c.value_in_use <> 0)
- OR (c.name = N'tempdb metadata memory-optimized' AND c.value_in_use <> 0)
- OR (c.name = N'lightweight pooling' AND c.value_in_use <> 0);
+ OR (c.name = N'tempdb metadata memory-optimized' AND c.value_in_use <> 0);
/*
TempDB Configuration Checks (not applicable to Azure SQL DB)
@@ -4339,6 +4345,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
category,
finding,
database_name,
+ object_name,
details,
url
)
@@ -4346,10 +4353,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 7003,
priority = 20, /* High: apps can't connect */
category = N'Database Configuration',
- finding =
- N'Restricted Access Mode: ' +
- d.user_access_desc,
+ finding = N'Restricted Access Mode',
database_name = d.name,
+ object_name = d.user_access_desc,
details =
N'Database is not in MULTI_USER mode. Current mode: ' +
d.user_access_desc +
@@ -4884,6 +4890,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
category,
finding,
database_name,
+ object_name,
details,
url
)
@@ -4891,8 +4898,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 7008,
priority = 30, /* Medium: data loss risk on crash */
category = N'Database Configuration',
- finding = N'Delayed Durability: ' + d.delayed_durability_desc,
+ finding = N'Delayed Durability',
database_name = d.name,
+ object_name = d.delayed_durability_desc,
details =
N'Database uses ' +
d.delayed_durability_desc +
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
index 12892ad5..01c741ff 100644
--- a/sp_PerfCheck/tests/run_tests.py
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -198,10 +198,11 @@ def parse_result_rows(stdout):
def find_config_finding(rows, option_name):
"""Return the #results row for a Non-Default Configuration finding on the
- given option, or None."""
- want = "Non-Default Configuration: " + option_name
+ given option, or None. finding is a stable label; the option name lives in
+ object_name (column 6), so matching here also proves that convention holds."""
for r in rows:
- if r[4] == want:
+ if (r[4] == "Non-Default Configuration"
+ and len(r) > 6 and r[6] == option_name):
return r
return None
@@ -356,11 +357,14 @@ def restore_all():
row is not None, "finding not emitted when forced")
if row is not None:
well_formed = (row[0] == "1000" and row[1] == "50"
- and row[3] == "Server Configuration")
+ and row[3] == "Server Configuration"
+ and row[4] == "Non-Default Configuration"
+ and row[6] == name)
R.check(grp, "forced finding row well-formed "
- "(check_id 1000, priority 50, Server Configuration)",
+ "(stable finding, setting name in object_name)",
well_formed,
- "check_id=%s priority=%s category=%s" % (row[0], row[1], row[3]))
+ "check_id=%s finding=%r object_name=%r"
+ % (row[0], row[4], row[6]))
details = row[7] if len(row) > 7 else ""
names_option = (name in details
and ("Current: %d" % forced_value) in details)
From 83d3e74bce445d74a82e7cf6012d5d5fa7204624 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 05:58:26 -0400
Subject: [PATCH 29/60] CI: run the sp_PerfCheck and sp_HumanEvents assertion
harnesses
The sql-tests workflow only ran basic-execution and help-output smoke tests, so
the assertion harnesses added recently ran only when someone remembered to. That
gap is exactly how behavioral regressions shipped: a harness existed but nothing
ran it. This wires the two self-contained harnesses into CI so they run on every
push to dev and every PR, across the 2017/2019/2022/2025 matrix.
PerfCheck and HumanEvents build and drop their own scratch databases, Extended
Events sessions, and configuration, so they need no external data - unlike the
IndexCleanup and ReproBuilder harnesses, which depend on StackOverflow2013 that
the CI containers do not have (a later phase).
The harnesses were written and validated against the go-based sqlcmd, so CI now
installs that package (already available from the Microsoft apt repo it
configures) alongside mssql-tools18, rather than risk output-parsing differences
from a different client. The harnesses' sqlcmd invocation is made configurable
through two environment variables - SQLCMD_BIN (default "sqlcmd") and
SQLCMD_CONN_ARGS (default empty) - so the same code runs locally unchanged and in
CI with "-C" to trust the container's self-signed certificate. Both changes are
no-ops locally: harnesses still pass 53/53 and 195/195 against SQL2022.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 30 ++++++++++++++++++++++++++++--
sp_HumanEvents/tests/run_tests.py | 16 ++++++++++++++--
sp_PerfCheck/tests/run_tests.py | 16 ++++++++++++++--
3 files changed, 56 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index 420f3118..c35c2909 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -41,7 +41,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Install sqlcmd
+ - name: Install sqlcmd tools
run: |
# Ubuntu 24.04 runners have Microsoft repo pre-configured; avoid Signed-By conflicts
if ! grep -rql 'packages.microsoft.com' /etc/apt/sources.list.d/ 2>/dev/null; then
@@ -50,7 +50,10 @@ jobs:
echo "deb [arch=amd64,signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${VERSION_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list
fi
sudo apt-get update
- sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18
+ # mssql-tools18 for the .sql smoke tests below; the go-based sqlcmd
+ # (on PATH as /usr/bin/sqlcmd) for the Python assertion harnesses,
+ # which were written and validated against that tool.
+ sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 sqlcmd
- name: Create test database
env:
@@ -105,3 +108,26 @@ jobs:
SA_PASSWORD: CI_Test#2026!
run: |
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -d DarlingData_CI_Test -i ".github/sql/test_basic_execution.sql"
+
+ # Assertion harnesses. These EXECUTE the output the procedures generate
+ # (findings, session DDL) rather than just running them, which is what
+ # catches the "reads correctly but is wrong when run" bugs the smoke tests
+ # above cannot. They are self-contained (build and drop their own scratch
+ # databases, sessions, and config); only PerfCheck and HumanEvents run
+ # here so far - IndexCleanup and ReproBuilder need a data source the CI
+ # containers do not have yet. They use the go-based sqlcmd via the
+ # SQLCMD_* environment overrides; -C trusts the container's self-signed
+ # certificate.
+ - name: sp_PerfCheck assertion harness
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ SQLCMD_CONN_ARGS: "-C"
+ run: |
+ python3 sp_PerfCheck/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
+
+ - name: sp_HumanEvents assertion harness
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ SQLCMD_CONN_ARGS: "-C"
+ run: |
+ python3 sp_HumanEvents/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
diff --git a/sp_HumanEvents/tests/run_tests.py b/sp_HumanEvents/tests/run_tests.py
index 69df0b59..8656514c 100644
--- a/sp_HumanEvents/tests/run_tests.py
+++ b/sp_HumanEvents/tests/run_tests.py
@@ -53,7 +53,9 @@
"""
import argparse
+import os
import re
+import shlex
import subprocess
import sys
@@ -79,6 +81,16 @@ def find_sql_errors(text):
# ---------------------------------------------------------------- sqlcmd plumbing
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment so
+ one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the
+ go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN
+ at its own binary and sets SQLCMD_CONN_ARGS to the cert-trust flag its
+ container connection needs (e.g. '-C')."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
def _sqlcmd(server, password, sql, database="master",
query_timeout=None, subprocess_timeout=120):
"""Run a batch and return (stdout, stderr) decoded as UTF-8.
@@ -90,8 +102,8 @@ def _sqlcmd(server, password, sql, database="master",
cancels the batch server-side -- used to stop the never-ending collector
loop); subprocess_timeout is a hard backstop on the whole process.
"""
- cmd = [
- "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ cmd = _sqlcmd_prefix() + [
+ "-S", server, "-U", "sa", "-P", password,
"-d", database,
"-W", # trim trailing spaces
"-h", "-1", # no result-set headers
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
index 01c741ff..bdf3a653 100644
--- a/sp_PerfCheck/tests/run_tests.py
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -47,7 +47,9 @@
"""
import argparse
+import os
import re
+import shlex
import subprocess
import sys
@@ -92,6 +94,16 @@ def find_sql_errors(text):
return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text)
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment so
+ one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the
+ go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN
+ at its own binary and sets SQLCMD_CONN_ARGS to the cert-trust flag its
+ container connection needs (e.g. '-C')."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
def _sqlcmd(server, password, sql, headers=True):
"""Run a batch and return (stdout, stderr) decoded as UTF-8.
@@ -100,8 +112,8 @@ def _sqlcmd(server, password, sql, headers=True):
that). Output is tab-delimited and trimmed (-W -s TAB) and the line width is
maxed (-w 65535) so wide rows are not wrapped mid-row.
"""
- cmd = [
- "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ cmd = _sqlcmd_prefix() + [
+ "-S", server, "-U", "sa", "-P", password,
"-d", "master",
"-W", # trim trailing spaces
"-w", "65535", # do not wrap wide rows
From d6a3e964abe14402e96ac4c5446f9cc60c741abb Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 06:02:16 -0400
Subject: [PATCH 30/60] CI: install go-sqlcmd from GitHub release (apt package
unavailable on runners)
The 'sqlcmd' apt package is not in the Microsoft repo for the ubuntu-noble
runners (E: Unable to locate package sqlcmd). Pull the latest linux-amd64
go-sqlcmd release binary instead, install to /usr/local/bin/sqlcmd, and point the
harness steps at it explicitly.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 30 ++++++++++++++++++++++++++----
1 file changed, 26 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index c35c2909..ba72f3a9 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -50,10 +50,30 @@ jobs:
echo "deb [arch=amd64,signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/${VERSION_ID}/prod ${VERSION_CODENAME} main" | sudo tee /etc/apt/sources.list.d/mssql-release.list
fi
sudo apt-get update
- # mssql-tools18 for the .sql smoke tests below; the go-based sqlcmd
- # (on PATH as /usr/bin/sqlcmd) for the Python assertion harnesses,
- # which were written and validated against that tool.
- sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 sqlcmd
+ sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18
+
+ - name: Install go-sqlcmd (for the assertion harnesses)
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ # The Python assertion harnesses were written and validated against the
+ # go-based sqlcmd, not mssql-tools18, so install that to avoid
+ # output-parsing differences. It is not in the apt repo for these
+ # runners, so pull the latest linux-amd64 release binary.
+ set -euxo pipefail
+ assets=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \
+ https://api.github.com/repos/microsoft/go-sqlcmd/releases/latest \
+ | grep -oE 'https://[^"]+' | grep -iE 'linux[-_]amd64.*\.tar\.(bz2|gz)$')
+ echo "$assets"
+ url=$(echo "$assets" | head -1)
+ test -n "$url"
+ mkdir -p /tmp/gosqlcmd
+ curl -fsSL -o /tmp/gosqlcmd.tar "$url"
+ tar -xf /tmp/gosqlcmd.tar -C /tmp/gosqlcmd
+ bin=$(find /tmp/gosqlcmd -type f -name sqlcmd | head -1)
+ test -n "$bin"
+ sudo install "$bin" /usr/local/bin/sqlcmd
+ /usr/local/bin/sqlcmd --version
- name: Create test database
env:
@@ -121,6 +141,7 @@ jobs:
- name: sp_PerfCheck assertion harness
env:
SA_PASSWORD: CI_Test#2026!
+ SQLCMD_BIN: /usr/local/bin/sqlcmd
SQLCMD_CONN_ARGS: "-C"
run: |
python3 sp_PerfCheck/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
@@ -128,6 +149,7 @@ jobs:
- name: sp_HumanEvents assertion harness
env:
SA_PASSWORD: CI_Test#2026!
+ SQLCMD_BIN: /usr/local/bin/sqlcmd
SQLCMD_CONN_ARGS: "-C"
run: |
python3 sp_HumanEvents/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
From ca3b1ddc527137d92933132e1f0c7b43dd0e316b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 06:04:57 -0400
Subject: [PATCH 31/60] CI: install the bundle into master so the assertion
harnesses find the procs
The harnesses connect to master (as on a local box), but CI installed the procs
only into DarlingData_CI_Test, so the preflight reported sp_PerfCheck missing.
Add a master install before the harness steps.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index ba72f3a9..f04f9edd 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -129,6 +129,15 @@ jobs:
run: |
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -d DarlingData_CI_Test -i ".github/sql/test_basic_execution.sql"
+ # The harnesses connect to master and run EXECUTE dbo.sp_... there (as they
+ # do on a local box, where these procs live in master), so install the
+ # bundle into master too. The smoke tests above use DarlingData_CI_Test.
+ - name: Install procedures into master (for the assertion harnesses)
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ run: |
+ /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -d master -i "Install-All/DarlingData.sql"
+
# Assertion harnesses. These EXECUTE the output the procedures generate
# (findings, session DDL) rather than just running them, which is what
# catches the "reads correctly but is wrong when run" bugs the smoke tests
From c02ca1b96db8e8d84f495bc06620406eaa666778 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 06:09:31 -0400
Subject: [PATCH 32/60] CI: temp continue-on-error on PerfCheck harness to
confirm HumanEvents runs on Linux
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index f04f9edd..1b128b50 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -148,6 +148,7 @@ jobs:
# SQLCMD_* environment overrides; -C trusts the container's self-signed
# certificate.
- name: sp_PerfCheck assertion harness
+ continue-on-error: true # TEMP: sp_PerfCheck errors on Linux containers (default trace); diagnosing
env:
SA_PASSWORD: CI_Test#2026!
SQLCMD_BIN: /usr/local/bin/sqlcmd
From 45f80c7da8ae13e9c8cb70d0dc78978be1952bca Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 06:39:11 -0400
Subject: [PATCH 33/60] sp_PerfCheck: degrade gracefully when the default trace
is unreadable
On Linux SQL Server sys.traces still lists a default trace, but
fn_trace_gettable cannot read it there and raises Msg 19049, which
aborted the whole procedure before it returned any findings. Wrap the
default-trace read in TRY/CATCH: on failure leave #trace_events empty
(the trace-based checks below then simply find nothing) and record one
informational finding (check_id 5004) noting the trace was unreadable.
Also covers a missing or corrupt trace file on Windows.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/sp_PerfCheck.sql | 161 +++++++++++++++++++++-------------
1 file changed, 98 insertions(+), 63 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index b37244df..86c10332 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -1,4 +1,4 @@
-SET ANSI_NULLS ON;
+SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
@@ -1541,70 +1541,105 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
(116, N'DBCC Event', N'Database'),
(137, N'Server Memory Change', N'Server');
- /* Get relevant events from default trace */
- INSERT INTO
- #trace_events
- (
- event_time,
- event_class,
- event_subclass,
- database_name,
- database_id,
- file_name,
- object_name,
- object_type,
- duration_ms,
- severity,
- success,
- error,
- text_data,
- file_growth,
- is_auto,
- spid
- )
- SELECT
- event_time = t.StartTime,
- event_class = t.EventClass,
- event_subclass = t.EventSubClass,
- database_name = DB_NAME(t.DatabaseID),
- database_id = t.DatabaseID,
- file_name = t.FileName,
- object_name = t.ObjectName,
- object_type = t.ObjectType,
- duration_ms = t.Duration / 1000, /* Duration is in microseconds, convert to ms */
- severity = t.Severity,
- success = t.Success,
- error = t.Error,
- text_data = t.TextData,
- file_growth = t.IntegerData, /* Size of growth in Data/Log Auto Grow event */
- is_auto = t.IsSystem,
- spid = t.SPID
- FROM sys.fn_trace_gettable(@trace_path, DEFAULT) AS t
- WHERE
- (
- /* Auto-grow and auto-shrink events */
- t.EventClass IN (92, 93, 94, 95)
- /* DBCC Events */
- OR
+ /*
+ Get relevant events from the default trace. On Linux SQL Server
+ sys.traces still lists a default trace, but fn_trace_gettable cannot
+ read it there and raises Msg 19049; the same guard also covers a
+ missing or unreadable trace file. On failure, leave #trace_events
+ empty - the trace-based checks below then simply find nothing - and
+ record one informational note, rather than letting the error abort
+ the whole procedure before it returns any findings.
+ */
+ BEGIN TRY
+ INSERT INTO
+ #trace_events
(
- t.EventClass = 116
- AND
- (
- t.TextData LIKE N'%FREEPROCCACHE%'
- OR t.TextData LIKE N'%FREESYSTEMCACHE%'
- OR t.TextData LIKE N'%DROPCLEANBUFFERS%'
- OR t.TextData LIKE N'%SHRINKDATABASE%'
- OR t.TextData LIKE N'%SHRINKFILE%'
- OR t.TextData LIKE N'%WRITEPAGE%'
- )
+ event_time,
+ event_class,
+ event_subclass,
+ database_name,
+ database_id,
+ file_name,
+ object_name,
+ object_type,
+ duration_ms,
+ severity,
+ success,
+ error,
+ text_data,
+ file_growth,
+ is_auto,
+ spid
)
- /* Server memory change events */
- OR t.EventClass = 137
- /* Deadlock events - typically not in default trace but including for completeness */
- OR t.EventClass = 148
- )
- /* Look back at the past 7 days of events at most */
- AND t.StartTime > DATEADD(DAY, -7, SYSDATETIME());
+ SELECT
+ event_time = t.StartTime,
+ event_class = t.EventClass,
+ event_subclass = t.EventSubClass,
+ database_name = DB_NAME(t.DatabaseID),
+ database_id = t.DatabaseID,
+ file_name = t.FileName,
+ object_name = t.ObjectName,
+ object_type = t.ObjectType,
+ duration_ms = t.Duration / 1000, /* Duration is in microseconds, convert to ms */
+ severity = t.Severity,
+ success = t.Success,
+ error = t.Error,
+ text_data = t.TextData,
+ file_growth = t.IntegerData, /* Size of growth in Data/Log Auto Grow event */
+ is_auto = t.IsSystem,
+ spid = t.SPID
+ FROM sys.fn_trace_gettable(@trace_path, DEFAULT) AS t
+ WHERE
+ (
+ /* Auto-grow and auto-shrink events */
+ t.EventClass IN (92, 93, 94, 95)
+ /* DBCC Events */
+ OR
+ (
+ t.EventClass = 116
+ AND
+ (
+ t.TextData LIKE N'%FREEPROCCACHE%'
+ OR t.TextData LIKE N'%FREESYSTEMCACHE%'
+ OR t.TextData LIKE N'%DROPCLEANBUFFERS%'
+ OR t.TextData LIKE N'%SHRINKDATABASE%'
+ OR t.TextData LIKE N'%SHRINKFILE%'
+ OR t.TextData LIKE N'%WRITEPAGE%'
+ )
+ )
+ /* Server memory change events */
+ OR t.EventClass = 137
+ /* Deadlock events - typically not in default trace but including for completeness */
+ OR t.EventClass = 148
+ )
+ /* Look back at the past 7 days of events at most */
+ AND t.StartTime > DATEADD(DAY, -7, SYSDATETIME());
+ END TRY
+ BEGIN CATCH
+ INSERT INTO
+ #results
+ (
+ check_id,
+ priority,
+ category,
+ finding,
+ details,
+ url
+ )
+ VALUES
+ (
+ 5004,
+ 50, /* Informational: platform limitation, not a problem */
+ N'Default Trace',
+ N'Default Trace Not Readable',
+ N'The default trace is registered but could not be read (' +
+ ERROR_MESSAGE() +
+ N'). Its auto-grow, auto-shrink, and DBCC findings are ' +
+ N'unavailable. This is expected on Linux SQL Server, where ' +
+ N'the default trace cannot be read via fn_trace_gettable.',
+ N'https://erikdarling.com/sp_perfcheck/'
+ );
+ END CATCH;
/* Update event names from map */
UPDATE
From 5f1109b9e82f2dd015b365e097eaec8a4e4d33c2 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 06:39:22 -0400
Subject: [PATCH 34/60] CI: connect the assertion harnesses with encryption
disabled
The go-sqlcmd the harnesses use failed to connect to the SQL Server 2017
container instantly: the modern Go TLS stack rejects that old container's
self-signed certificate outright, and -C (trust cert) does not bypass it,
so the harness read the empty result as "proc not installed." Disable
encryption (-N disable) so no TLS handshake happens - there is nothing to
secure on a localhost throwaway container. Removes the temporary
continue-on-error on the sp_PerfCheck step now that it degrades cleanly
on Linux and passes there.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 11 ++++++-----
sp_HumanEvents/tests/run_tests.py | 5 +++--
sp_PerfCheck/tests/run_tests.py | 5 +++--
3 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index 1b128b50..11399427 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -145,14 +145,15 @@ jobs:
# databases, sessions, and config); only PerfCheck and HumanEvents run
# here so far - IndexCleanup and ReproBuilder need a data source the CI
# containers do not have yet. They use the go-based sqlcmd via the
- # SQLCMD_* environment overrides; -C trusts the container's self-signed
- # certificate.
+ # SQLCMD_* environment overrides. Encryption is disabled (-N disable):
+ # the modern Go TLS stack rejects the SQL Server 2017 container's
+ # self-signed certificate outright, and -C (trust cert) does not bypass
+ # that; there is nothing to secure on a localhost throwaway container.
- name: sp_PerfCheck assertion harness
- continue-on-error: true # TEMP: sp_PerfCheck errors on Linux containers (default trace); diagnosing
env:
SA_PASSWORD: CI_Test#2026!
SQLCMD_BIN: /usr/local/bin/sqlcmd
- SQLCMD_CONN_ARGS: "-C"
+ SQLCMD_CONN_ARGS: "-C -N disable"
run: |
python3 sp_PerfCheck/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
@@ -160,6 +161,6 @@ jobs:
env:
SA_PASSWORD: CI_Test#2026!
SQLCMD_BIN: /usr/local/bin/sqlcmd
- SQLCMD_CONN_ARGS: "-C"
+ SQLCMD_CONN_ARGS: "-C -N disable"
run: |
python3 sp_HumanEvents/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
diff --git a/sp_HumanEvents/tests/run_tests.py b/sp_HumanEvents/tests/run_tests.py
index 8656514c..b2e72ca2 100644
--- a/sp_HumanEvents/tests/run_tests.py
+++ b/sp_HumanEvents/tests/run_tests.py
@@ -85,8 +85,9 @@ def _sqlcmd_prefix():
"""The sqlcmd binary plus any connection args, overridable via environment so
one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the
go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN
- at its own binary and sets SQLCMD_CONN_ARGS to the cert-trust flag its
- container connection needs (e.g. '-C')."""
+ at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ self-signed cert and disable encryption, since the modern Go TLS stack
+ rejects the SQL Server 2017 container's certificate outright."""
return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
os.environ.get("SQLCMD_CONN_ARGS", ""))
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
index bdf3a653..39f4c885 100644
--- a/sp_PerfCheck/tests/run_tests.py
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -98,8 +98,9 @@ def _sqlcmd_prefix():
"""The sqlcmd binary plus any connection args, overridable via environment so
one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the
go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN
- at its own binary and sets SQLCMD_CONN_ARGS to the cert-trust flag its
- container connection needs (e.g. '-C')."""
+ at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ self-signed cert and disable encryption, since the modern Go TLS stack
+ rejects the SQL Server 2017 container's certificate outright."""
return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
os.environ.get("SQLCMD_CONN_ARGS", ""))
From 3c65b1c0c82f1b37f4ec6022515869dfeceb7e3c Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 06:47:44 -0400
Subject: [PATCH 35/60] sp_PerfCheck harness: flush pending config before
baselining
The "zero net configuration change" meta-assertion failed on the SQL
Server 2017 CI container: its image ships 'clr strict security' configured
on but not yet in use (value=1, value_in_use=0), a staged change. The
harness's own RECONFIGURE (for its SAFE_OPTIONS) applied that pending
change mid-run, so it surfaced as a net config change the harness never
made. Issue RECONFIGURE before capturing the baseline so the comparison
measures only the harness's own effect. Verified by staging an equivalent
pending change locally: fails without this, passes with it.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/tests/run_tests.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
index 39f4c885..97db6da9 100644
--- a/sp_PerfCheck/tests/run_tests.py
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -316,6 +316,14 @@ def structural_tests(server, password, R):
def forced_condition_tests(server, password, R):
"""Bidirectional forced-condition assertions on the check_id 1000
Non-Default Configuration check. Captures and restores exact originals."""
+ # Flush any pre-existing pending config change (value <> value_in_use) before
+ # baselining. Some images ship one: the SQL Server 2017 CI container has
+ # 'clr strict security' configured on but not yet in use. The harness's own
+ # RECONFIGURE calls would apply that staged change mid-run, and it would then
+ # surface as a spurious net change the harness never made. _sqlcmd does not
+ # raise on a SQL error, so a rejected RECONFIGURE is tolerated here.
+ _sqlcmd(server, password, "RECONFIGURE;", headers=False)
+
# Snapshot the entire config before touching anything.
before = snapshot_config(server, password)
From 491341708dcf1dee20fca83c7906d8586fcfcbcd Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 07:43:11 -0400
Subject: [PATCH 36/60] tests: make IndexCleanup/ReproBuilder harnesses
CI-capable
The PerfCheck and HumanEvents harnesses already read SQLCMD_BIN and
SQLCMD_CONN_ARGS so CI can point them at go-sqlcmd with the connection
flags the containers need. Give the IndexCleanup and ReproBuilder runners
the same _sqlcmd_prefix() abstraction instead of a hardcoded bare "sqlcmd"
with no flags, so they connect in CI (go-sqlcmd, -C -N disable) exactly as
they do locally (bare sqlcmd, empty args). Also resolve adversarial_test.sql
relative to __file__ in IndexCleanup's run_tests.py so it can run from any
working directory, matching its sibling runners.
Co-Authored-By: Claude Opus 4.8
---
sp_IndexCleanup/tests/fixture_cases_test.py | 15 ++++++++++++-
sp_IndexCleanup/tests/no_access_test.py | 20 +++++++++++++++--
sp_IndexCleanup/tests/rule_coverage_test.py | 15 ++++++++++++-
sp_IndexCleanup/tests/run_tests.py | 23 +++++++++++++++++---
sp_QueryReproBuilder/tests/mutation_check.py | 17 +++++++++++++--
sp_QueryReproBuilder/tests/run_tests.py | 15 ++++++++++++-
6 files changed, 95 insertions(+), 10 deletions(-)
diff --git a/sp_IndexCleanup/tests/fixture_cases_test.py b/sp_IndexCleanup/tests/fixture_cases_test.py
index d684bf55..fd1e0c14 100644
--- a/sp_IndexCleanup/tests/fixture_cases_test.py
+++ b/sp_IndexCleanup/tests/fixture_cases_test.py
@@ -43,6 +43,7 @@
import os
import re
+import shlex
import subprocess
import sys
import tempfile
@@ -51,6 +52,17 @@
HERE = os.path.dirname(os.path.abspath(__file__))
FIXTURE_FILE = os.path.join(HERE, "fixtures_more_dupe_indexes.sql")
+
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment
+ so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to
+ 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the
+ go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ container's self-signed cert and disable encryption, which the modern Go
+ TLS stack needs to connect to the SQL Server 2017 container."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
# Rows that represent a dedupe action. A COMPRESSION SCRIPT row is not one of
# these: an index that gets no dedupe action still gets compression advice, and
# that is expected, so "no dedupe action" means none of these three.
@@ -61,7 +73,8 @@ def run_sqlcmd(server, password, input_file=None, query=None,
database="StackOverflow2013", timeout=600):
"""Run SQL from a file or a query string and capture output."""
cmd = [
- "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ *_sqlcmd_prefix(),
+ "-S", server, "-U", "sa", "-P", password,
"-d", database,
"-W", # trim trailing spaces
"-s", "\t", # tab delimiter
diff --git a/sp_IndexCleanup/tests/no_access_test.py b/sp_IndexCleanup/tests/no_access_test.py
index 9385dce4..7745f8a5 100644
--- a/sp_IndexCleanup/tests/no_access_test.py
+++ b/sp_IndexCleanup/tests/no_access_test.py
@@ -21,11 +21,25 @@
python no_access_test.py [--server SQL2022] [--password "L!nt0044"]
"""
+import os
+import shlex
import subprocess
import sys
import time
import secrets
+
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment
+ so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to
+ 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the
+ go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ container's self-signed cert and disable encryption, which the modern Go
+ TLS stack needs to connect to the SQL Server 2017 container."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
TEST_LOGIN = f"sp_indexcleanup_no_access_test_{secrets.token_hex(4)}"
TEST_PASSWORD = "T3st!" + secrets.token_hex(8) + "Aa1"
TIMEOUT_SECONDS = 30
@@ -34,7 +48,8 @@
def run_sqlcmd_as_sa(server, sa_password, sql):
"""Execute a SQL statement as sa."""
cmd = [
- "sqlcmd", "-S", server, "-U", "sa", "-P", sa_password,
+ *_sqlcmd_prefix(),
+ "-S", server, "-U", "sa", "-P", sa_password,
"-d", "master", "-b", "-Q", sql,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
@@ -46,7 +61,8 @@ def run_sqlcmd_as_sa(server, sa_password, sql):
def run_sqlcmd_as_test_login(server, sql, timeout):
"""Execute a SQL statement as the test login, with a wall-clock timeout."""
cmd = [
- "sqlcmd", "-S", server, "-U", TEST_LOGIN, "-P", TEST_PASSWORD,
+ *_sqlcmd_prefix(),
+ "-S", server, "-U", TEST_LOGIN, "-P", TEST_PASSWORD,
"-d", "master", "-Q", sql,
]
start = time.monotonic()
diff --git a/sp_IndexCleanup/tests/rule_coverage_test.py b/sp_IndexCleanup/tests/rule_coverage_test.py
index 06661af6..021faf39 100644
--- a/sp_IndexCleanup/tests/rule_coverage_test.py
+++ b/sp_IndexCleanup/tests/rule_coverage_test.py
@@ -130,11 +130,23 @@
import os
import re
+import shlex
import subprocess
import sys
import tempfile
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment
+ so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to
+ 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the
+ go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ container's self-signed cert and disable encryption, which the modern Go
+ TLS stack needs to connect to the SQL Server 2017 container."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
TEST_DATABASE = "Crap"
# Group F's two throwaway databases. CrapA is processed FIRST and is the one
@@ -851,7 +863,8 @@ def run_sqlcmd(server, password, input_file=None, query=None,
database=TEST_DATABASE, timeout=600):
"""Run SQL from a file or a query string and capture output."""
cmd = [
- "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ *_sqlcmd_prefix(),
+ "-S", server, "-U", "sa", "-P", password,
"-d", database,
"-W", # trim trailing spaces
"-s", "\t", # tab delimiter
diff --git a/sp_IndexCleanup/tests/run_tests.py b/sp_IndexCleanup/tests/run_tests.py
index 65f1566e..b921964b 100644
--- a/sp_IndexCleanup/tests/run_tests.py
+++ b/sp_IndexCleanup/tests/run_tests.py
@@ -7,9 +7,25 @@
python run_tests.py [--server SQL2022] [--password "L!nt0044"]
"""
+import os
+import re
+import shlex
import subprocess
import sys
-import re
+
+
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment
+ so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to
+ 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the
+ go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ container's self-signed cert and disable encryption, which the modern Go
+ TLS stack needs to connect to the SQL Server 2017 container."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
+HERE = os.path.dirname(os.path.abspath(__file__))
def find_sql_errors(text):
@@ -28,9 +44,10 @@ def find_sql_errors(text):
def run_sqlcmd(server, password):
"""Run the test SQL and capture output."""
cmd = [
- "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ *_sqlcmd_prefix(),
+ "-S", server, "-U", "sa", "-P", password,
"-d", "StackOverflow2013",
- "-i", "adversarial_test.sql",
+ "-i", os.path.join(HERE, "adversarial_test.sql"),
"-W", # trim trailing spaces
"-s", "\t", # tab delimiter
]
diff --git a/sp_QueryReproBuilder/tests/mutation_check.py b/sp_QueryReproBuilder/tests/mutation_check.py
index c4d171af..eeec0975 100644
--- a/sp_QueryReproBuilder/tests/mutation_check.py
+++ b/sp_QueryReproBuilder/tests/mutation_check.py
@@ -31,11 +31,24 @@
import atexit
import os
import re
+import shlex
import shutil
import subprocess
import sys
import tempfile
+
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment
+ so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to
+ 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the
+ go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ container's self-signed cert and disable encryption, which the modern Go
+ TLS stack needs to connect to the SQL Server 2017 container."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
HERE = os.path.dirname(os.path.abspath(__file__))
REPO_PROC = os.path.normpath(os.path.join(HERE, "..", "sp_QueryReproBuilder.sql"))
RUN_TESTS = os.path.join(HERE, "run_tests.py")
@@ -93,8 +106,8 @@ def pattern_check(base):
def install(path, server, password, label):
r = subprocess.run(
- ["sqlcmd", "-S", server, "-U", "sa", "-P", password, "-d", "master",
- "-i", path, "-b"],
+ [*_sqlcmd_prefix(), "-S", server, "-U", "sa", "-P", password,
+ "-d", "master", "-i", path, "-b"],
capture_output=True, text=True, timeout=180)
if r.returncode != 0:
print(" install(%s) FAILED rc=%d: %s"
diff --git a/sp_QueryReproBuilder/tests/run_tests.py b/sp_QueryReproBuilder/tests/run_tests.py
index 3413d7a0..1b408a38 100644
--- a/sp_QueryReproBuilder/tests/run_tests.py
+++ b/sp_QueryReproBuilder/tests/run_tests.py
@@ -38,6 +38,7 @@
import base64
import os
import re
+import shlex
import shutil
import subprocess
import sys
@@ -45,6 +46,17 @@
HERE = os.path.dirname(os.path.abspath(__file__))
GEN_TEMPLATE = os.path.join(HERE, "template_generate.sql")
+
+
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment
+ so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to
+ 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the
+ go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ container's self-signed cert and disable encryption, which the modern Go
+ TLS stack needs to connect to the SQL Server 2017 container."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
EXEC_TEMPLATE = os.path.join(HERE, "template_execute.sql")
# Per-case .sql files are ephemeral; keep them out of the committed tests dir.
@@ -151,7 +163,8 @@ def _write_sql(path, sql):
def _run_sql_file(server, password, path):
cmd = [
- "sqlcmd", "-S", server, "-U", "sa", "-P", password,
+ *_sqlcmd_prefix(),
+ "-S", server, "-U", "sa", "-P", password,
"-d", "master", "-i", path, "-y", "0", "-h", "-1",
]
# Capture bytes and decode as UTF-8: go-sqlcmd emits UTF-8 on stdout, so a
From 79fca69c36fc43e445c197287fc4e8efc64795b1 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 07:43:12 -0400
Subject: [PATCH 37/60] CI: run the IndexCleanup and ReproBuilder assertion
harnesses
ReproBuilder is fully portable (embedded plan constants; its one table
case uses a tempdb fixture), so it just runs. IndexCleanup needs a
StackOverflow2013.dbo.Users table the containers lack; add a fixture that
builds a faithful-schema synthetic stand-in (exact Users columns, clustered
PK, Brent Ozar's DropIndexes helper, ~500k varied rows, EmailHash all-NULL
like the real data). Real values are irrelevant: the harness forces its
index reads with hints and every rule is structural. Verified locally on a
2017 instance that never had the database - all four IndexCleanup runners
(107 assertions) and ReproBuilder (237) pass.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 50 ++-
.../tests/fixture_stackoverflow2013.sql | 394 ++++++++++++++++++
2 files changed, 437 insertions(+), 7 deletions(-)
create mode 100644 sp_IndexCleanup/tests/fixture_stackoverflow2013.sql
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index 11399427..c7e4d487 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -142,13 +142,13 @@ jobs:
# (findings, session DDL) rather than just running them, which is what
# catches the "reads correctly but is wrong when run" bugs the smoke tests
# above cannot. They are self-contained (build and drop their own scratch
- # databases, sessions, and config); only PerfCheck and HumanEvents run
- # here so far - IndexCleanup and ReproBuilder need a data source the CI
- # containers do not have yet. They use the go-based sqlcmd via the
- # SQLCMD_* environment overrides. Encryption is disabled (-N disable):
- # the modern Go TLS stack rejects the SQL Server 2017 container's
- # self-signed certificate outright, and -C (trust cert) does not bypass
- # that; there is nothing to secure on a localhost throwaway container.
+ # databases, sessions, and config), except sp_IndexCleanup, which needs a
+ # StackOverflow2013.dbo.Users table that the fixture step below creates.
+ # They use the go-based sqlcmd via the SQLCMD_* environment overrides.
+ # Encryption is disabled (-N disable): the modern Go TLS stack rejects the
+ # SQL Server 2017 container's self-signed certificate outright, and -C
+ # (trust cert) does not bypass that; there is nothing to secure on a
+ # localhost throwaway container.
- name: sp_PerfCheck assertion harness
env:
SA_PASSWORD: CI_Test#2026!
@@ -164,3 +164,39 @@ jobs:
SQLCMD_CONN_ARGS: "-C -N disable"
run: |
python3 sp_HumanEvents/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
+
+ # sp_IndexCleanup's harness needs a StackOverflow2013 with a Users table.
+ # The containers have no such database, so build a faithful-schema
+ # synthetic stand-in. Real values are irrelevant: the harness forces its
+ # index reads and every rule is structural (dedupe / overlap / usage
+ # floor). See sp_IndexCleanup/tests/fixture_stackoverflow2013.sql.
+ - name: Create StackOverflow2013 fixture (for the sp_IndexCleanup harness)
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ run: |
+ /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "sp_IndexCleanup/tests/fixture_stackoverflow2013.sql"
+
+ # Four runners: adversarial synthetic tables, the Users dedupe cases (which
+ # also EXECUTE every generated MERGE/DISABLE script), rule coverage in its
+ # own Crap databases, and the no-database-access preflight guard.
+ - name: sp_IndexCleanup assertion harness
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ SQLCMD_BIN: /usr/local/bin/sqlcmd
+ SQLCMD_CONN_ARGS: "-C -N disable"
+ run: |
+ python3 sp_IndexCleanup/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
+ python3 sp_IndexCleanup/tests/fixture_cases_test.py --server localhost --password "$SA_PASSWORD"
+ python3 sp_IndexCleanup/tests/rule_coverage_test.py --server localhost --password "$SA_PASSWORD"
+ python3 sp_IndexCleanup/tests/no_access_test.py --server localhost --password "$SA_PASSWORD"
+
+ # ReproBuilder is fully portable: every plan is an embedded string constant
+ # and its one table-dependent case builds a fixture in tempdb. No user
+ # database needed.
+ - name: sp_QueryReproBuilder assertion harness
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ SQLCMD_BIN: /usr/local/bin/sqlcmd
+ SQLCMD_CONN_ARGS: "-C -N disable"
+ run: |
+ python3 sp_QueryReproBuilder/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
diff --git a/sp_IndexCleanup/tests/fixture_stackoverflow2013.sql b/sp_IndexCleanup/tests/fixture_stackoverflow2013.sql
new file mode 100644
index 00000000..cc916a39
--- /dev/null
+++ b/sp_IndexCleanup/tests/fixture_stackoverflow2013.sql
@@ -0,0 +1,394 @@
+/*
+=============================================================================
+sp_IndexCleanup test fixture: a scratch StackOverflow2013 for CI
+=============================================================================
+
+Erik's lab boxes already have the real StackOverflow2013 (~2.4M-row Users),
+which the harness assumes. The CI containers do not, so this builds a
+faithful-schema, synthetic-data stand-in that lets the whole IndexCleanup
+suite run anywhere.
+
+What the harness actually needs from this database:
+
+ - run_tests.py connects here and builds its own test_ic_* tables, so
+ it only needs the database to EXIST.
+ - fixture_cases_test needs dbo.Users (exact schema) and dbo.DropIndexes.
+ - rule_coverage_test builds its own Crap/CrapA/CrapB databases.
+ - no_access_test creates and drops its own login.
+
+No assertion depends on the row VALUES: reads are forced with WITH (INDEX =)
+hints and every rule is structural (dedupe / overlap / usage-floor). The data
+only needs the right schema, a unique Id (identity), populated NOT NULL
+columns, and enough rows for indexes to carry real pages. EmailHash is left
+all-NULL to match the real database, so the filtered EmailHash indexes behave
+identically.
+
+This is test infrastructure, not shipped product code. dbo.DropIndexes is
+Brent Ozar's helper from the Stack Overflow sample-database tooling, embedded
+verbatim so the fixtures drop the same way they do on a real box.
+
+Safe to re-run: it drops and rebuilds StackOverflow2013 from scratch. Do not
+point it at a database you care about.
+=============================================================================
+*/
+
+USE master;
+GO
+
+/*
+Drop any existing copy first so the fixture is idempotent.
+*/
+IF DB_ID(N'StackOverflow2013') IS NOT NULL
+BEGIN
+ ALTER DATABASE StackOverflow2013 SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE StackOverflow2013;
+END;
+GO
+
+CREATE DATABASE StackOverflow2013;
+GO
+
+/*
+SIMPLE recovery keeps the log from bloating during the bulk load.
+*/
+ALTER DATABASE StackOverflow2013 SET RECOVERY SIMPLE;
+GO
+
+USE StackOverflow2013;
+GO
+
+/*
+The Users table, matching the real StackOverflow2013 schema exactly:
+column order, types, nullability, and the clustered primary key on Id.
+*/
+CREATE TABLE
+ dbo.Users
+(
+ Id integer NOT NULL IDENTITY(1, 1),
+ AboutMe nvarchar(max) NULL,
+ Age integer NULL,
+ CreationDate datetime NOT NULL,
+ DisplayName nvarchar(40) NOT NULL,
+ DownVotes integer NOT NULL,
+ EmailHash nvarchar(40) NULL,
+ LastAccessDate datetime NOT NULL,
+ Location nvarchar(100) NULL,
+ Reputation integer NOT NULL,
+ UpVotes integer NOT NULL,
+ Views integer NOT NULL,
+ WebsiteUrl nvarchar(200) NULL,
+ AccountId integer NULL,
+ CONSTRAINT PK_Users_Id
+ PRIMARY KEY CLUSTERED (Id)
+);
+GO
+
+/*
+Populate the table set-based from a tally. Values are deterministic and
+varied across the columns the fixtures index and filter on (Reputation spans
+the 1000/2000/10000 thresholds, CreationDate spans 2008-2013, DisplayName
+varies its first letter for LIKE 'A%'/'B%'). EmailHash stays NULL to match
+the real data. AboutMe stays NULL to keep the LOB column lean; the fixtures
+only need it to exist as an includable column, not to carry data.
+
+Row count: 500k is a balance -- large enough that every index carries real
+pages, small enough that fixture_cases_test's index builds and generated-
+script executions stay fast in a container. Tune @row_target if needed.
+*/
+DECLARE
+ @row_target integer = 500000;
+
+WITH
+ tally AS
+(
+ SELECT TOP (@row_target)
+ n =
+ ROW_NUMBER() OVER
+ (
+ ORDER BY
+ (SELECT NULL)
+ )
+ FROM sys.all_columns AS a
+ CROSS JOIN sys.all_columns AS b
+)
+INSERT INTO
+ dbo.Users
+WITH
+ (TABLOCK)
+(
+ AboutMe,
+ Age,
+ CreationDate,
+ DisplayName,
+ DownVotes,
+ EmailHash,
+ LastAccessDate,
+ Location,
+ Reputation,
+ UpVotes,
+ Views,
+ WebsiteUrl,
+ AccountId
+)
+SELECT
+ AboutMe = CONVERT(nvarchar(max), NULL),
+ Age =
+ CASE
+ WHEN t.n % 7 = 0
+ THEN NULL
+ ELSE 13 + (t.n % 80)
+ END,
+ CreationDate = DATEADD(DAY, t.n % 2000, CONVERT(datetime, N'20080731')),
+ DisplayName = NCHAR(65 + (t.n % 26)) + N'ser_' + CONVERT(nvarchar(20), t.n),
+ DownVotes = t.n % 50,
+ EmailHash = CONVERT(nvarchar(40), NULL),
+ LastAccessDate = DATEADD(DAY, t.n % 2000, CONVERT(datetime, N'20080801')),
+ Location =
+ CASE t.n % 5
+ WHEN 0 THEN N'New York'
+ WHEN 1 THEN N'San Francisco'
+ WHEN 2 THEN N'London'
+ WHEN 3 THEN NULL
+ ELSE N'Seattle'
+ END,
+ Reputation = 1 + (t.n % 1000000),
+ UpVotes = t.n % 200,
+ Views = t.n % 1000,
+ WebsiteUrl =
+ CASE
+ WHEN t.n % 3 = 0
+ THEN N'https://example.com/' + CONVERT(nvarchar(20), t.n)
+ ELSE NULL
+ END,
+ AccountId = t.n
+FROM tally AS t
+OPTION(MAXDOP 1);
+GO
+
+/*
+dbo.DropIndexes -- Brent Ozar's helper from the Stack Overflow sample-database
+tooling, embedded verbatim (CREATE changed to CREATE OR ALTER). The fixtures
+begin with EXECUTE dbo.DropIndexes to reset to a clean clustered-only Users
+between runs. With its defaults it drops nonclustered indexes and unique
+constraints but leaves the clustered primary key in place.
+*/
+CREATE OR ALTER PROCEDURE
+ dbo.DropIndexes
+(
+ @SchemaName sysname = 'dbo',
+ @TableName sysname = NULL,
+ @WhatToDrop varchar(10) = 'Everything',
+ @ExceptIndexNames nvarchar(MAX) = NULL,
+ @Debug bit = 'false'
+)
+ AS
+BEGIN
+ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+ SET NOCOUNT ON;
+ SET STATISTICS XML OFF;
+
+ CREATE TABLE
+ #commands
+ (
+ ID integer IDENTITY PRIMARY KEY,
+ Command nvarchar(4000) NOT NULL
+ );
+ CREATE TABLE
+ #errors
+ (
+ ID integer IDENTITY PRIMARY KEY,
+ Command nvarchar(4000) NOT NULL,
+ ErrorMessage nvarchar(4000) NOT NULL
+ );
+
+ CREATE TABLE
+ #ExceptIndexNames
+ (
+ IndexName nvarchar(4000) NOT NULL
+ );
+
+ INSERT INTO
+ #ExceptIndexNames
+ (
+ IndexName
+ )
+ SELECT DISTINCT
+ IndexName = UPPER(LTRIM(RTRIM(s.value)))
+ FROM STRING_SPLIT(@ExceptIndexNames, N',') AS s
+ WHERE s.value LIKE N'_%';
+
+ DECLARE
+ @CurrentCommand nvarchar(4000);
+
+ IF
+ (
+ UPPER(@WhatToDrop) LIKE 'C%'
+ OR UPPER(@WhatToDrop) LIKE 'E%'
+ )
+ BEGIN
+ INSERT INTO
+ #commands
+ (
+ Command
+ )
+ SELECT
+ N'ALTER TABLE '
+ + QUOTENAME(s.name)
+ + N'.'
+ + QUOTENAME(t.name)
+ + N' DROP CONSTRAINT '
+ + QUOTENAME(o.name)
+ + N';'
+ FROM sys.objects AS o
+ JOIN sys.schemas AS s
+ ON o.schema_id = s.schema_id
+ JOIN sys.tables AS t
+ ON o.parent_object_id = t.object_id
+ WHERE o.type IN (N'C', N'F', N'UQ')
+ AND s.name = ISNULL(@SchemaName, s.name) COLLATE DATABASE_DEFAULT
+ AND t.name = ISNULL(@TableName, t.name) COLLATE DATABASE_DEFAULT
+ AND UPPER(o.name) NOT IN
+ (
+ SELECT
+ e.IndexName COLLATE DATABASE_DEFAULT
+ FROM #ExceptIndexNames AS e
+ WHERE e.IndexName IS NOT NULL
+ );
+ END;
+
+ IF
+ (
+ UPPER(@WhatToDrop) LIKE 'I%'
+ OR UPPER(@WhatToDrop) LIKE 'E%'
+ )
+ BEGIN
+ INSERT INTO
+ #commands
+ (
+ Command
+ )
+ SELECT
+ N'DROP INDEX '
+ + QUOTENAME(i.name)
+ + N' ON '
+ + QUOTENAME(s.name)
+ + N'.'
+ + QUOTENAME(t.name)
+ + N';'
+ FROM sys.tables t
+ JOIN sys.schemas AS s
+ ON t.schema_id = s.schema_id
+ JOIN sys.indexes i
+ ON t.object_id = i.object_id
+ WHERE i.type NOT IN (0, 1, 5)
+ AND s.name = ISNULL(@SchemaName, s.name) COLLATE DATABASE_DEFAULT
+ AND t.name = ISNULL(@TableName, t.name) COLLATE DATABASE_DEFAULT
+ AND UPPER(i.name) NOT IN
+ (
+ SELECT
+ e.IndexName COLLATE DATABASE_DEFAULT
+ FROM #ExceptIndexNames AS e
+ WHERE e.IndexName IS NOT NULL
+ );
+
+ INSERT INTO
+ #commands
+ (
+ Command
+ )
+ SELECT
+ N'DROP STATISTICS '
+ + QUOTENAME(sc.name)
+ + N'.'
+ + QUOTENAME(t.name)
+ + N'.'
+ + QUOTENAME(s.name)
+ + N';'
+ FROM sys.stats AS s
+ JOIN sys.tables AS t
+ ON s.object_id = t.object_id
+ JOIN sys.schemas AS sc
+ ON t.schema_id = sc.schema_id
+ WHERE NOT EXISTS
+ (
+ SELECT
+ 1/0
+ FROM sys.indexes AS i
+ WHERE i.name = s.name
+ )
+ AND sc.name = ISNULL(@SchemaName, s.name)
+ AND t.name = ISNULL(@TableName, t.name)
+ AND t.name NOT LIKE N'sys%';
+ END;
+ IF @Debug = 1
+ BEGIN
+ SELECT
+ c.*
+ FROM #commands AS c
+ ORDER BY
+ c.ID;
+ END;
+
+ DECLARE
+ @result_cursor CURSOR
+
+ SET @result_cursor =
+ CURSOR
+ LOCAL
+ SCROLL
+ DYNAMIC
+ READ_ONLY
+ FOR
+ SELECT
+ c.Command
+ FROM #commands AS c;
+
+ OPEN @result_cursor;
+
+ FETCH FIRST
+ FROM @result_cursor
+ INTO @CurrentCommand;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ BEGIN TRY
+ PRINT @CurrentCommand;
+
+ EXECUTE sys.sp_executesql
+ @CurrentCommand;
+ END TRY
+ BEGIN CATCH
+ INSERT
+ #errors
+ (
+ Command,
+ ErrorMessage
+ )
+ VALUES
+ (
+ @CurrentCommand,
+ ERROR_MESSAGE()
+ );
+ END CATCH
+
+ FETCH NEXT
+ FROM @result_cursor
+ INTO @CurrentCommand;
+ END;
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #errors AS e
+ )
+ BEGIN
+ SELECT
+ e.ID,
+ e.Command,
+ e.ErrorMessage
+ FROM #errors AS e
+ ORDER BY
+ e.ID;
+ END;
+END;
+GO
From 36be02ee625a627fbbace7c5053b95d4d8b0ea84 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:01:09 -0400
Subject: [PATCH 38/60] CI: hold rule_coverage_test out of the IndexCleanup
harness
rule_coverage_test.py passes locally but fails on the freshly-started CI
containers: several positive controls assert the procedure still emits a
PAGE compression recommendation for small tables, and on a just-started
instance those COMPRESSION SCRIPT rows are absent in the single-database
(@database_name) path -- while present via @get_all_databases (groups F/G
pass) and present on any established instance. The other three IndexCleanup
runners (run_tests, fixture_cases, no_access) and ReproBuilder are wired in
and green. rule_coverage stays a local-only test until the fresh-instance
compression behavior is root-caused.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index c7e4d487..4111ad7a 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -176,9 +176,19 @@ jobs:
run: |
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "sp_IndexCleanup/tests/fixture_stackoverflow2013.sql"
- # Four runners: adversarial synthetic tables, the Users dedupe cases (which
- # also EXECUTE every generated MERGE/DISABLE script), rule coverage in its
- # own Crap databases, and the no-database-access preflight guard.
+ # Three runners: adversarial synthetic tables, the Users dedupe cases
+ # (which also EXECUTE every generated MERGE/DISABLE script), and the
+ # no-database-access preflight guard.
+ #
+ # rule_coverage_test.py is deliberately NOT run here. Several of its
+ # positive controls assert the procedure still recommends PAGE compression
+ # for small tables, and on a freshly-started instance (which the CI
+ # containers always are) those COMPRESSION SCRIPT rows are absent in the
+ # single-database (@database_name) path -- though present via
+ # @get_all_databases and present on any established instance. Whether that
+ # is a real gap in the procedure or a stats-not-yet-settled artifact needs
+ # a fresh-instance repro to root-cause; until then this runner stays a
+ # local-only test (see sp_IndexCleanup/tests/README.md).
- name: sp_IndexCleanup assertion harness
env:
SA_PASSWORD: CI_Test#2026!
@@ -187,7 +197,6 @@ jobs:
run: |
python3 sp_IndexCleanup/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
python3 sp_IndexCleanup/tests/fixture_cases_test.py --server localhost --password "$SA_PASSWORD"
- python3 sp_IndexCleanup/tests/rule_coverage_test.py --server localhost --password "$SA_PASSWORD"
python3 sp_IndexCleanup/tests/no_access_test.py --server localhost --password "$SA_PASSWORD"
# ReproBuilder is fully portable: every plan is an embedded string constant
From 31859085f70ca0bba746d8b725b29d6f12c639f6 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:06:32 -0400
Subject: [PATCH 39/60] docs: reflect IndexCleanup harness CI status in tests
README
Three runners now run in CI against a synthetic StackOverflow2013 fixture;
rule_coverage_test.py stays local-only pending the fresh-instance
compression finding.
Co-Authored-By: Claude Opus 4.8
---
sp_IndexCleanup/tests/README.md | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/sp_IndexCleanup/tests/README.md b/sp_IndexCleanup/tests/README.md
index 20c18194..5e78b401 100644
--- a/sp_IndexCleanup/tests/README.md
+++ b/sp_IndexCleanup/tests/README.md
@@ -63,9 +63,23 @@ asserts the guard fired and suppressed it. It never silently skips, and it prove
the index was analyzed and genuinely has zero reads first, so the absence of an
`Unused Index` row means the guard, not an invisible index.
-These are **not** wired into CI. `.github/workflows/sql-tests.yml` only runs
-basic-execution and help-output smoke tests, which is why behavioral regressions
-have shipped before. Run these by hand.
+### CI
+
+`run_tests.py`, `fixture_cases_test.py`, and `no_access_test.py` run in CI on
+every 2017/2019/2022/2025 container. They need a `StackOverflow2013` with a
+`Users` table, which the containers do not have, so CI first builds a
+faithful-schema synthetic one from `fixture_stackoverflow2013.sql` (exact Users
+columns, clustered PK, the `DropIndexes` helper, ~500k varied rows). Real values
+are not needed: the harness forces its index reads and every rule is structural.
+
+`rule_coverage_test.py` is **local only**, not in CI. Several of its positive
+controls assert the procedure still recommends PAGE compression for small tables,
+and on a freshly-started instance -- which the CI containers always are -- those
+`COMPRESSION SCRIPT` rows are absent in the single-database (`@database_name`)
+path, though present via `@get_all_databases` (groups F/G) and present on any
+established instance. Whether that is a real gap or a stats-not-yet-settled
+artifact is unresolved; until it is, run this one by hand on a long-lived
+instance. Green CI does **not** mean `rule_coverage_test.py` passed.
## Fixtures (manual)
From 1b15e22710027c4a470d27d984dd7cd33a16020a Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 08:37:49 -0400
Subject: [PATCH 40/60] sp_IndexCleanup rule_coverage: create the Crap scratch
database
rule_coverage_test.py assumed a pre-existing Crap database. On a long-lived
instance it persists between runs (nothing drops it), so the gap was
invisible; on a freshly-started instance -- every CI container -- Crap has
never existed. run_sql_script connects with -d Crap, so setup failed with
Msg 4060 ("Cannot open database"), which is Level 11 and slipped past the
Level-16 error check: the fixture silently did not build and every
Crap-scoped assertion failed with "found 0". This masqueraded as a
procedure bug (missing PAGE compression recommendations); it was not -- the
procedure emits them correctly on a fresh instance, confirmed directly.
Create Crap if absent (CrapA/CrapB were already created this way) and treat
Msg 4060/911 as fatal in sql_errors so a missing scratch database fails
loudly instead of leaving the suite green. rule_coverage now runs on a fresh
instance, so wire it back into CI as the fourth IndexCleanup runner.
Verified by dropping Crap on a local instance: fails identically to CI
without the fix, passes 40/40 with it.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 17 +++---------
sp_IndexCleanup/tests/README.md | 29 ++++++++++-----------
sp_IndexCleanup/tests/rule_coverage_test.py | 22 ++++++++++++++--
3 files changed, 38 insertions(+), 30 deletions(-)
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index 4111ad7a..1d729198 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -176,19 +176,9 @@ jobs:
run: |
/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "sp_IndexCleanup/tests/fixture_stackoverflow2013.sql"
- # Three runners: adversarial synthetic tables, the Users dedupe cases
- # (which also EXECUTE every generated MERGE/DISABLE script), and the
- # no-database-access preflight guard.
- #
- # rule_coverage_test.py is deliberately NOT run here. Several of its
- # positive controls assert the procedure still recommends PAGE compression
- # for small tables, and on a freshly-started instance (which the CI
- # containers always are) those COMPRESSION SCRIPT rows are absent in the
- # single-database (@database_name) path -- though present via
- # @get_all_databases and present on any established instance. Whether that
- # is a real gap in the procedure or a stats-not-yet-settled artifact needs
- # a fresh-instance repro to root-cause; until then this runner stays a
- # local-only test (see sp_IndexCleanup/tests/README.md).
+ # Four runners: adversarial synthetic tables, the Users dedupe cases
+ # (which also EXECUTE every generated MERGE/DISABLE script), rule coverage
+ # in its own scratch databases, and the no-database-access preflight guard.
- name: sp_IndexCleanup assertion harness
env:
SA_PASSWORD: CI_Test#2026!
@@ -197,6 +187,7 @@ jobs:
run: |
python3 sp_IndexCleanup/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
python3 sp_IndexCleanup/tests/fixture_cases_test.py --server localhost --password "$SA_PASSWORD"
+ python3 sp_IndexCleanup/tests/rule_coverage_test.py --server localhost --password "$SA_PASSWORD"
python3 sp_IndexCleanup/tests/no_access_test.py --server localhost --password "$SA_PASSWORD"
# ReproBuilder is fully portable: every plan is an embedded string constant
diff --git a/sp_IndexCleanup/tests/README.md b/sp_IndexCleanup/tests/README.md
index 5e78b401..25aee97d 100644
--- a/sp_IndexCleanup/tests/README.md
+++ b/sp_IndexCleanup/tests/README.md
@@ -65,21 +65,20 @@ the index was analyzed and genuinely has zero reads first, so the absence of an
### CI
-`run_tests.py`, `fixture_cases_test.py`, and `no_access_test.py` run in CI on
-every 2017/2019/2022/2025 container. They need a `StackOverflow2013` with a
-`Users` table, which the containers do not have, so CI first builds a
-faithful-schema synthetic one from `fixture_stackoverflow2013.sql` (exact Users
-columns, clustered PK, the `DropIndexes` helper, ~500k varied rows). Real values
-are not needed: the harness forces its index reads and every rule is structural.
-
-`rule_coverage_test.py` is **local only**, not in CI. Several of its positive
-controls assert the procedure still recommends PAGE compression for small tables,
-and on a freshly-started instance -- which the CI containers always are -- those
-`COMPRESSION SCRIPT` rows are absent in the single-database (`@database_name`)
-path, though present via `@get_all_databases` (groups F/G) and present on any
-established instance. Whether that is a real gap or a stats-not-yet-settled
-artifact is unresolved; until it is, run this one by hand on a long-lived
-instance. Green CI does **not** mean `rule_coverage_test.py` passed.
+All four runners run in CI on every 2017/2019/2022/2025 container. Three of them
+(`run_tests.py`, `fixture_cases_test.py`, `no_access_test.py`) need a
+`StackOverflow2013` with a `Users` table, which the containers do not have, so CI
+first builds a faithful-schema synthetic one from `fixture_stackoverflow2013.sql`
+(exact Users columns, clustered PK, the `DropIndexes` helper, ~500k varied rows).
+Real values are not needed: the harness forces its index reads and every rule is
+structural.
+
+`rule_coverage_test.py` creates its own scratch `Crap`/`CrapA`/`CrapB`
+databases. It used to assume `Crap` already existed -- true on a long-lived box
+where it persists between runs, false on a fresh CI container -- and the missing
+database failed silently (the `-d Crap` connection error is Msg 4060, Level 11,
+below the Level-16 error check). It now creates `Crap` if absent and treats Msg
+4060/911 as fatal, so it runs cleanly on a fresh instance.
## Fixtures (manual)
diff --git a/sp_IndexCleanup/tests/rule_coverage_test.py b/sp_IndexCleanup/tests/rule_coverage_test.py
index 021faf39..fa88674c 100644
--- a/sp_IndexCleanup/tests/rule_coverage_test.py
+++ b/sp_IndexCleanup/tests/rule_coverage_test.py
@@ -901,8 +901,14 @@ def sql_errors(stdout, stderr):
stderr only, which is how adversarial_test.sql managed to fail on every run
for months while the suite stayed green.
"""
- return (re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stdout or "") +
- re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stderr or ""))
+ # Level 16-19 are the usual "this statement failed" errors. Msg 4060 and
+ # Msg 911 ("Cannot open database") are only Level 11, but they mean the
+ # script never ran at all -- a missing scratch database, exactly the failure
+ # that once left this suite silently green on a fresh instance. Catch those
+ # explicitly so they fail loudly too.
+ pattern = r"Msg (?:\d+, Level 1[6-9]|4060|911)[^\n]*"
+ return (re.findall(pattern, stdout or "") +
+ re.findall(pattern, stderr or ""))
def parse_output(stdout):
@@ -1541,6 +1547,18 @@ def main():
print("verification, not a skip.")
print()
+ # Create the scratch database if it is not already there. On an established
+ # instance it persists between runs (nothing drops it), but a fresh instance
+ # -- every CI container -- has never had it. run_sql_script below connects
+ # with -d Crap, so without this the setup connection fails with Msg 4060
+ # ("Cannot open database"), which is Level 11 and slips past the Level-16
+ # error check, silently leaving the fixture unbuilt and every Crap-scoped
+ # assertion to fail with "found 0". CrapA/CrapB are already created this way.
+ print("Ensuring the %s database exists..." % TEST_DATABASE)
+ run_sqlcmd(server, password, database="master",
+ query="IF DB_ID('%s') IS NULL CREATE DATABASE %s;"
+ % (TEST_DATABASE, TEST_DATABASE))
+
print("Building synthetic fixture in %s..." % TEST_DATABASE)
stdout, stderr = run_sql_script(server, password, SETUP_SQL)
From 49cc31edb9a07554ecb4f21b6009a4abc2b1d00f Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 09:24:10 -0400
Subject: [PATCH 41/60] sp_PerfCheck: cut noise findings and flag bad config,
not changed config
The check set drowned real findings in priority-50 checklist noise and had
some logic backwards. On a normal server this cut a 27-finding run to 10,
all meaningful.
Deleted, none of which indicate a problem:
- 7005 ANSI Settings Require Review (fired on every database; the SESSION
SET options the driver sends are what matter, not database-level ANSI)
- 7007 Non-Default Target Recovery Time
- 4107 Resource Governor Enabled (bare "it is on" informational)
- 1000 Non-Default Configuration, the generic "changed from default" list --
it flagged correct MAXDOP / cost threshold / max memory values as findings
Inverted the cost-threshold check (1004) to flag the BAD value instead of the
changed one: it now fires when CTFP is under 50, escalating to High (20) at or
below the terrible default of 5 and staying Low (40) between 6 and 49.
Kept 7103 (log growth <> 64 MB) -- it looks like a nitpick but is a real SQL
2022 check: log auto-growth only gets instant file initialization at 64 MB.
Reworked the harness config test to match: it now forces CTFP sane / default /
low and asserts absence, presence, and the priority escalation bidirectionally.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/sp_PerfCheck.sql | 207 +++-----------------------------
sp_PerfCheck/tests/run_tests.py | 174 +++++++++++++--------------
2 files changed, 103 insertions(+), 278 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index 86c10332..02b06787 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -1314,38 +1314,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
N'Resource Governor',
N'Enabled';
- /* Add informational message about Resource Governor with query suggestion */
- INSERT INTO
- #results
- (
- check_id,
- priority,
- category,
- finding,
- details,
- url
- )
- SELECT
- check_id = 4107,
- priority = 50, /* Informational: may be intentional */
- category = N'Resource Governor',
- finding = N'Resource Governor Enabled',
- details =
- N'Resource Governor is enabled on this instance. This affects workload resource allocation and may ' +
- N'impact performance by limiting resources available to various workloads. ' +
- N'For more details, run these queries to explore your configuration:' + NCHAR(13) + NCHAR(10) +
- N'/* Resource Governor configuration */' + NCHAR(13) + NCHAR(10) +
- N'SELECT c.* FROM sys.resource_governor_configuration AS c;' + NCHAR(13) + NCHAR(10) +
- N'/* Resource pools and their settings */' + NCHAR(13) + NCHAR(10) +
- N'SELECT p.* FROM sys.dm_resource_governor_resource_pools AS p;' + NCHAR(13) + NCHAR(10) +
- N'/* Workload groups and their settings */' + NCHAR(13) + NCHAR(10) +
- N'SELECT wg.* FROM sys.dm_resource_governor_workload_groups AS wg;' + NCHAR(13) + NCHAR(10) +
- N'/* Classifier function (if configured) */' + NCHAR(13) + NCHAR(10) +
- N'SELECT cf.* FROM sys.resource_governor_configuration AS gc' + NCHAR(13) + NCHAR(10) +
- N'CROSS APPLY (SELECT OBJECT_NAME(gc.classifier_function_id) AS classifier_function_name) AS cf;',
- url = N'https://erikdarling.com/sp_perfcheck/#ResourceGovernor'
- FROM sys.resource_governor_configuration AS rgc
- WHERE rgc.is_enabled = 1;
END
ELSE
BEGIN
@@ -3194,87 +3162,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
IF @azure_sql_db = 0 /* Skip these checks for Azure SQL DB */
BEGIN
- /* Check for non-default configuration values */
- INSERT INTO
- #results
- (
- check_id,
- priority,
- category,
- finding,
- object_name,
- details,
- url
- )
- SELECT
- check_id = 1000,
- priority = 50, /* Informational: non-default config */
- category = N'Server Configuration',
- /*
- Stable label; the setting name that varies per row goes in
- object_name so this finding groups and the setting is filterable.
- */
- finding = N'Non-Default Configuration',
- object_name = c.name,
- details =
- N'Configuration option "' + c.name +
- N'" has been changed from the default. Current: ' +
- CONVERT(nvarchar(50), c.value_in_use) +
- CASE
- /* Configuration options from your lists */
- WHEN c.name = N'access check cache bucket count' THEN N', Default: 0'
- WHEN c.name = N'access check cache quota' THEN N', Default: 0'
- WHEN c.name = N'Ad Hoc Distributed Queries' THEN N', Default: 0'
- WHEN c.name = N'ADR cleaner retry timeout (min)' THEN N', Default: 120'
- WHEN c.name = N'ADR Cleaner Thread Count' THEN N', Default: 1'
- WHEN c.name = N'ADR Preallocation Factor' THEN N', Default: 4'
- WHEN c.name = N'affinity mask' THEN N', Default: 0'
- WHEN c.name = N'affinity I/O mask' THEN N', Default: 0'
- WHEN c.name = N'affinity64 mask' THEN N', Default: 0'
- WHEN c.name = N'affinity64 I/O mask' THEN N', Default: 0'
- WHEN c.name = N'cost threshold for parallelism' THEN N', Default: 5'
- WHEN c.name = N'max degree of parallelism' THEN N', Default: 0'
- WHEN c.name = N'max server memory (MB)' THEN N', Default: 2147483647'
- WHEN c.name = N'max worker threads' THEN N', Default: 0'
- WHEN c.name = N'min memory per query (KB)' THEN N', Default: 1024'
- WHEN c.name = N'min server memory (MB)' THEN N', Default: 0'
- WHEN c.name = N'optimize for ad hoc workloads' THEN N', Default: 0'
- WHEN c.name = N'priority boost' THEN N', Default: 0'
- WHEN c.name = N'query governor cost limit' THEN N', Default: 0'
- WHEN c.name = N'recovery interval (min)' THEN N', Default: 0'
- WHEN c.name = N'tempdb metadata memory-optimized' THEN N', Default: 0'
- WHEN c.name = N'lightweight pooling' THEN N', Default: 0'
- ELSE N', Default: Unknown'
- END,
- url = N'https://erikdarling.com/sp_perfcheck/#ServerSettings'
- FROM sys.configurations AS c
- WHERE
- /* Access check cache settings */
- (c.name = N'access check cache bucket count' AND c.value_in_use <> 0)
- OR (c.name = N'access check cache quota' AND c.value_in_use <> 0)
- OR (c.name = N'Ad Hoc Distributed Queries' AND c.value_in_use <> 0)
- /* ADR settings */
- OR (c.name = N'ADR cleaner retry timeout (min)' AND c.value_in_use NOT IN (0, 15, 120))
- OR (c.name = N'ADR Cleaner Thread Count' AND c.value_in_use <> 1)
- OR (c.name = N'ADR Preallocation Factor' AND c.value_in_use NOT IN (0, 4))
- /*
- Affinity masks (1008-1011), priority boost (1005), and lightweight
- pooling (1006) have their own dedicated checks that fire on exactly
- the same non-default condition, so they are excluded here - otherwise
- each would be reported twice, once generically and once specifically.
- */
- /* Common performance settings */
- OR (c.name = N'cost threshold for parallelism' AND c.value_in_use <> 5)
- OR (c.name = N'max degree of parallelism' AND c.value_in_use <> 0)
- OR (c.name = N'max server memory (MB)' AND c.value_in_use <> 2147483647)
- OR (c.name = N'max worker threads' AND c.value_in_use <> 0)
- OR (c.name = N'min memory per query (KB)' AND c.value_in_use <> 1024)
- OR (c.name = N'min server memory (MB)' AND c.value_in_use NOT IN (0, 16))
- OR (c.name = N'optimize for ad hoc workloads' AND c.value_in_use <> 0)
- OR (c.name = N'query governor cost limit' AND c.value_in_use <> 0)
- OR (c.name = N'recovery interval (min)' AND c.value_in_use <> 0)
- OR (c.name = N'tempdb metadata memory-optimized' AND c.value_in_use <> 0);
-
/*
TempDB Configuration Checks (not applicable to Azure SQL DB)
*/
@@ -3721,8 +3608,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
);
END;
- /* Cost Threshold for Parallelism check */
- IF @cost_threshold <= 5
+ /*
+ Cost threshold for parallelism set too low. The default of 5 is far too
+ low for modern hardware: it shoves trivial queries into parallel plans,
+ burning CPU and worker threads on work that runs faster single-threaded.
+ Flag anything under 50 (a sane starting point), escalating when it is
+ still at or near the terrible default.
+ */
+ IF @cost_threshold < 50
BEGIN
INSERT INTO
#results
@@ -3737,12 +3630,18 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
VALUES
(
1004,
- 40, /* Low: config recommendation */
+ CASE
+ WHEN @cost_threshold <= 5
+ THEN 20 /* High: still at or near the default of 5 */
+ ELSE 40 /* Low: configured, but lower than recommended */
+ END,
N'Server Configuration',
- N'Low Cost Threshold for Parallelism',
+ N'Cost Threshold for Parallelism Too Low',
N'Cost threshold for parallelism is set to ' +
CONVERT(nvarchar(10), @cost_threshold) +
- N'. Low values can cause excessive parallelism for small queries.',
+ N'. Low values push trivial queries into parallel plans, wasting CPU ' +
+ N'and worker threads on work that runs faster single-threaded. A ' +
+ N'starting point of 50 is far more reasonable than the default of 5.',
N'https://erikdarling.com/sp_perfcheck/#CostThreshold'
);
END;
@@ -4448,51 +4347,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
OR d.is_auto_update_stats_on = 0
);
- /* Check ANSI settings that might cause issues */
- INSERT INTO
- #results
- (
- check_id,
- priority,
- category,
- finding,
- database_name,
- details,
- url
- )
- SELECT
- check_id = 7005,
- priority = 50, /* Informational */
- category = N'Database Configuration',
- finding = N'ANSI Settings Require Review',
- database_name = d.name,
- details =
- N'One or more ANSI settings differ from recommended best practices: ' +
- CASE WHEN d.is_ansi_null_default_on = 0 THEN N'ANSI_NULL_DEFAULT OFF (recommended ON), ' ELSE N'' END +
- CASE WHEN d.is_ansi_nulls_on = 0 THEN N'ANSI_NULLS OFF (recommended ON), ' ELSE N'' END +
- CASE WHEN d.is_ansi_padding_on = 0 THEN N'ANSI_PADDING OFF (recommended ON), ' ELSE N'' END +
- CASE WHEN d.is_ansi_warnings_on = 0 THEN N'ANSI_WARNINGS OFF (recommended ON), ' ELSE N'' END +
- CASE WHEN d.is_arithabort_on = 0 THEN N'ARITHABORT OFF (recommended ON in many contexts), ' ELSE N'' END +
- CASE WHEN d.is_concat_null_yields_null_on = 0 THEN N'CONCAT_NULL_YIELDS_NULL OFF (recommended ON), ' ELSE N'' END +
- CASE WHEN d.is_numeric_roundabort_on = 1 THEN N'NUMERIC_ROUNDABORT ON (recommended OFF), ' ELSE N'' END +
- CASE WHEN d.is_quoted_identifier_on = 0 THEN N'QUOTED_IDENTIFIER OFF (recommended ON), ' ELSE N'' END +
- N'These settings may lead to inconsistent behavior, reduced feature compatibility, or unexpected query results ' +
- N'if they do not align with recommended best practices.',
- url = N'https://erikdarling.com/sp_perfcheck/#ANSISettings'
- FROM #databases AS d
- WHERE d.database_id = @current_database_id
- AND
- (
- d.is_ansi_null_default_on = 0
- OR d.is_ansi_nulls_on = 0
- OR d.is_ansi_padding_on = 0
- OR d.is_ansi_warnings_on = 0
- OR d.is_arithabort_on = 0
- OR d.is_concat_null_yields_null_on = 0
- OR d.is_numeric_roundabort_on = 1
- OR d.is_quoted_identifier_on = 0
- );
-
/* Check Query Store Status */
INSERT INTO
#results
@@ -4889,33 +4743,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
END;
END CATCH;
- /* Check for non-default target recovery time */
- INSERT INTO
- #results
- (
- check_id,
- priority,
- category,
- finding,
- database_name,
- details,
- url
- )
- SELECT
- check_id = 7007,
- priority = 50, /* Informational */
- category = N'Database Configuration',
- finding = N'Non-Default Target Recovery Time',
- database_name = d.name,
- details =
- N'Database target recovery time is ' +
- CONVERT(nvarchar(20), d.target_recovery_time_in_seconds) +
- N' seconds, which differs from the default of 60 seconds. This affects checkpoint frequency and recovery time.',
- url = N'https://erikdarling.com/sp_perfcheck/#RecoveryTime'
- FROM #databases AS d
- WHERE d.database_id = @current_database_id
- AND d.target_recovery_time_in_seconds <> 60;
-
/* Check transaction durability */
INSERT INTO
#results
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
index 97db6da9..9b132fc2 100644
--- a/sp_PerfCheck/tests/run_tests.py
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -22,10 +22,11 @@
* Forced-condition (the high-value core): conditions this harness can safely
create and reset, proven BIDIRECTIONALLY (finding absent when the condition
is not present, finding present when it is):
- - Server config: the "Non-Default Configuration" check (check_id 1000)
- reads sys.configurations. For a small set of SAFE options the harness
- forces each to a non-default value and back, and names the option in the
- details. Every option's original value_in_use is captured first and
+ - Server config: the "Cost Threshold for Parallelism Too Low" check
+ (check_id 1004) reads sys.configurations. The harness forces CTFP to a
+ sane value (absent), the default of 5 (present, High), and a low-but-
+ not-default value (present, Low), proving both the finding and its
+ severity escalation. The original value_in_use is captured first and
restored precisely in a finally block that runs even on assertion
failure. The instance is never left reconfigured.
- Database config: Auto-Shrink Enabled (7001) and Auto Update Statistics
@@ -54,18 +55,15 @@
import sys
-# The "Non-Default Configuration" check (check_id 1000) fires when an option's
-# value_in_use differs from the listed default. These three are SAFE to flip on
-# a test instance: none require a restart, none are dangerous (no max server
-# memory, no affinity), and nothing the CI does depends on them. Each is:
-# (option name, default value, forced non-default value)
-# All three are "advanced" options, so 'show advanced options' must be 1 while
-# they are configured; the harness sets it and restores it.
-FORCED_OPTIONS = [
- ("cost threshold for parallelism", 5, 55),
- ("optimize for ad hoc workloads", 0, 1),
- ("access check cache bucket count", 0, 256),
-]
+# The Cost Threshold for Parallelism check (check_id 1004) fires when CTFP is
+# set below 50, escalating to High (priority 20) at or below the default of 5
+# and staying Low (priority 40) between 6 and 49. CTFP is an advanced option
+# that needs no restart, so it is safe to flip on a test instance; the harness
+# sets 'show advanced options' while configuring it and restores both.
+CTFP = "cost threshold for parallelism"
+CTFP_SANE = 55 # >= 50: no finding
+CTFP_DEFAULT = 5 # bad, and at the default: finding at High priority (20)
+CTFP_LOW = 25 # bad, below 50 but above the default: finding at Low (40)
# Column names expected in the #results header (shape-regression guard).
RESULTS_COLUMNS = [
@@ -209,13 +207,11 @@ def parse_result_rows(stdout):
return rows
-def find_config_finding(rows, option_name):
- """Return the #results row for a Non-Default Configuration finding on the
- given option, or None. finding is a stable label; the option name lives in
- object_name (column 6), so matching here also proves that convention holds."""
+def find_ctfp_finding(rows):
+ """Return the #results row for the Cost Threshold for Parallelism Too Low
+ finding (check_id 1004), or None."""
for r in rows:
- if (r[4] == "Non-Default Configuration"
- and len(r) > 6 and r[6] == option_name):
+ if r[4] == "Cost Threshold for Parallelism Too Low":
return r
return None
@@ -314,8 +310,10 @@ def structural_tests(server, password, R):
def forced_condition_tests(server, password, R):
- """Bidirectional forced-condition assertions on the check_id 1000
- Non-Default Configuration check. Captures and restores exact originals."""
+ """Bidirectional forced-condition assertions on the check_id 1004 Cost
+ Threshold for Parallelism check: ABSENT when CTFP is sane (>= 50), PRESENT
+ and High at the default of 5, PRESENT but only Low between 6 and 49.
+ Captures and restores the original value."""
# Flush any pre-existing pending config change (value <> value_in_use) before
# baselining. Some images ship one: the SQL Server 2017 CI container has
# 'clr strict security' configured on but not yet in use. The harness's own
@@ -327,82 +325,82 @@ def forced_condition_tests(server, password, R):
# Snapshot the entire config before touching anything.
before = snapshot_config(server, password)
- # Capture originals: 'show advanced options' plus every forced option.
saw_advanced = get_value_in_use(server, password, "show advanced options")
- originals = {}
- for (name, _default, _forced) in FORCED_OPTIONS:
- originals[name] = get_value_in_use(server, password, name)
+ original_ctfp = get_value_in_use(server, password, CTFP)
def restore_all():
- # Restore every touched option to its captured original, then restore
- # 'show advanced options'. One RECONFIGURE at the end promotes them all.
- for (name, _d, _f) in FORCED_OPTIONS:
- _sqlcmd(server, password,
- "SET NOCOUNT ON; EXECUTE sys.sp_configure '%s', %d; RECONFIGURE;"
- % (_esc(name), originals[name]))
+ # Restore CTFP, then 'show advanced options'. RECONFIGURE promotes both.
+ _sqlcmd(server, password,
+ "SET NOCOUNT ON; EXECUTE sys.sp_configure '%s', %d; RECONFIGURE;"
+ % (_esc(CTFP), original_ctfp))
_sqlcmd(server, password,
"SET NOCOUNT ON; EXECUTE sys.sp_configure 'show advanced options', %d; RECONFIGURE;"
% saw_advanced)
+ grp = "Config[cost threshold for parallelism]"
try:
- # All chosen options are advanced; make sure they are configurable.
err = set_option(server, password, "show advanced options", 1)
R.check("Config", "setup: 'show advanced options' configurable",
not err, str(err))
- for (name, default_value, forced_value) in FORCED_OPTIONS:
- grp = "Config[%s]" % name
-
- # ---- ABSENT at the default value ----------------------------
- out, e = run_perfcheck_with_option(server, password, name, default_value)
- combined = out + "\n" + e
- R.check(grp, "no severe error on default-value run",
- not find_sql_errors(combined), str(find_sql_errors(combined)))
- # positive control: the check machinery actually ran and produced
- # output, so "absent" is not a vacuous pass.
- R.check(grp, "positive control: #server_info populated at default",
- SERVER_INFO_MARKER in out, "Run Date not found")
- rows = parse_result_rows(out)
- R.check(grp, "finding ABSENT when option = default (%d)" % default_value,
- find_config_finding(rows, name) is None,
- "unexpected finding present at default")
-
- # ---- PRESENT when forced to a non-default value -------------
- out, e = run_perfcheck_with_option(server, password, name, forced_value)
- combined = out + "\n" + e
- R.check(grp, "no severe error on forced-value run",
- not find_sql_errors(combined), str(find_sql_errors(combined)))
- rows = parse_result_rows(out)
- row = find_config_finding(rows, name)
- R.check(grp, "finding PRESENT when option = forced (%d)" % forced_value,
- row is not None, "finding not emitted when forced")
- if row is not None:
- well_formed = (row[0] == "1000" and row[1] == "50"
- and row[3] == "Server Configuration"
- and row[4] == "Non-Default Configuration"
- and row[6] == name)
- R.check(grp, "forced finding row well-formed "
- "(stable finding, setting name in object_name)",
- well_formed,
- "check_id=%s finding=%r object_name=%r"
- % (row[0], row[4], row[6]))
- details = row[7] if len(row) > 7 else ""
- names_option = (name in details
- and ("Current: %d" % forced_value) in details)
- R.check(grp, "forced finding details names the option and value",
- names_option, "details=%r" % details[:160])
- else:
- R.check(grp, "forced finding row well-formed "
- "(check_id 1000, priority 50, Server Configuration)",
- False, "no row to inspect")
- R.check(grp, "forced finding details names the option and value",
- False, "no row to inspect")
-
- # ---- Restore THIS option to its exact original --------------
- set_option(server, password, name, originals[name])
- now = get_value_in_use(server, password, name)
- R.check(grp, "option restored to original value_in_use (%d)" % originals[name],
- now == originals[name], "value_in_use is now %d" % now)
+ # ---- ABSENT when CTFP is sane (>= 50) -------------------------------
+ out, e = run_perfcheck_with_option(server, password, CTFP, CTFP_SANE)
+ combined = out + "\n" + e
+ R.check(grp, "no severe error on sane-value run",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ # positive control: the check machinery ran and produced output, so
+ # "absent" is not a vacuous pass.
+ R.check(grp, "positive control: #server_info populated at sane value",
+ SERVER_INFO_MARKER in out, "Run Date not found")
+ rows = parse_result_rows(out)
+ R.check(grp, "finding ABSENT when CTFP >= 50 (%d)" % CTFP_SANE,
+ find_ctfp_finding(rows) is None,
+ "unexpected CTFP finding at a sane value")
+
+ # ---- PRESENT and HIGH at the default of 5 --------------------------
+ out, e = run_perfcheck_with_option(server, password, CTFP, CTFP_DEFAULT)
+ combined = out + "\n" + e
+ R.check(grp, "no severe error on default-value run",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ rows = parse_result_rows(out)
+ row = find_ctfp_finding(rows)
+ R.check(grp, "finding PRESENT when CTFP = default (%d)" % CTFP_DEFAULT,
+ row is not None, "finding not emitted at the default")
+ if row is not None:
+ well_formed = (row[0] == "1004" and row[1] == "20"
+ and row[3] == "Server Configuration"
+ and row[4] == "Cost Threshold for Parallelism Too Low")
+ R.check(grp, "default finding well-formed "
+ "(check_id 1004, High priority 20, Server Configuration)",
+ well_formed,
+ "check_id=%s priority=%s finding=%r"
+ % (row[0], row[1], row[4]))
+ details = row[7] if len(row) > 7 else ""
+ R.check(grp, "default finding details name the value",
+ ("set to %d" % CTFP_DEFAULT) in details,
+ "details=%r" % details[:160])
+ else:
+ R.check(grp, "default finding well-formed "
+ "(check_id 1004, High priority 20, Server Configuration)",
+ False, "no row to inspect")
+ R.check(grp, "default finding details name the value",
+ False, "no row to inspect")
+
+ # ---- PRESENT but only LOW between 6 and 49 -------------------------
+ out, e = run_perfcheck_with_option(server, password, CTFP, CTFP_LOW)
+ rows = parse_result_rows(out)
+ row = find_ctfp_finding(rows)
+ R.check(grp, "finding PRESENT when CTFP low-but-not-default (%d)" % CTFP_LOW,
+ row is not None, "finding not emitted at a low value")
+ R.check(grp, "priority is Low (40), not High, when CTFP is 6-49",
+ row is not None and row[1] == "40",
+ "priority=%s" % (row[1] if row else "no row"))
+
+ # ---- Restore CTFP to its exact original ----------------------------
+ set_option(server, password, CTFP, original_ctfp)
+ now = get_value_in_use(server, password, CTFP)
+ R.check(grp, "option restored to original value_in_use (%d)" % original_ctfp,
+ now == original_ctfp, "value_in_use is now %d" % now)
finally:
# Safety net: restore everything even if an exception was raised.
restore_all()
From 5225f3d2fc6752bfc129a3634cba7c828a0b20ad Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 10:01:53 -0400
Subject: [PATCH 42/60] sp_PerfCheck Phase 2: real wait analysis, sane
storage/memory thresholds, trace-flag interpretation
Health-check depth on the cumulative/config signals (live problems remain
sp_PressureDetector's job).
Wait stats (6001): stop labeling every wait a flat "High Impact Wait Type".
Name the finding by category so the list reads as a diagnosis (Storage /
Lock / Memory / TempDB / CPU / Transaction Log / Parallelism ...), put the
wait type and its meaning in object_name, and calibrate severity by category:
resource-pressure waits earn High by dominating uptime OR by long average
waits; parallelism tops out at Medium (it's a cost-threshold/MAXDOP symptom);
the rest stay Low. Idle-box CXCONSUMER drops from a flagged wait to a Low
"Parallelism Waits" note.
Storage (3001/3002/3003): the slow-read/write thresholds defaulted to 500ms,
which is catastrophic-storage territory, so real latency never surfaced. Drop
to 20ms (data-file I/O should be under 20ms), with High at 5x (100ms).
Memory grants (4101/4103): these are cumulative counts since startup, so
firing High on any count > 0 over-reacts to a handful of transient events.
Scale severity with magnitude instead.
Trace flags (1012, new): the global flags were only listed in server-info.
Interpret the notable ones with per-flag meaning and severity -- 1211/3608/
3609 High, 1224/834 Medium, redundant-on-2016+ ones (1117/1118/2371) and
behavior-changers (4199/8048) Low -- leaving benign flags in the list only.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/sp_PerfCheck.sql | 192 +++++++++++++++++++++++++++-------
1 file changed, 157 insertions(+), 35 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index 02b06787..c8c64817 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -283,8 +283,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@has_percent_growth bit,
@has_fixed_growth bit,
/* Storage performance variables */
- @slow_read_ms decimal(10, 2) = 500.0, /* Threshold for slow reads (ms) */
- @slow_write_ms decimal(10, 2) = 500.0, /* Threshold for slow writes (ms) */
+ @slow_read_ms decimal(10, 2) = 20.0, /* Slow read threshold (ms); data-file reads should be under 20ms */
+ @slow_write_ms decimal(10, 2) = 20.0, /* Slow write threshold (ms); data-file writes should be under 20ms */
/* Set threshold for "slow" autogrowth (in ms) */
@slow_autogrow_ms integer = 1000, /* 1 second */
@trace_path nvarchar(260),
@@ -972,14 +972,21 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
SELECT
check_id = 4101,
- priority = 20, /* High: active memory spills */
+ priority =
+ CASE
+ WHEN MAX(ders.forced_grant_count) > 10000
+ THEN 20 /* High: heavy, sustained memory pressure */
+ WHEN MAX(ders.forced_grant_count) > 100
+ THEN 30 /* Medium */
+ ELSE 40 /* Low: a handful since startup, likely transient */
+ END,
category = N'Memory Pressure',
finding = N'Memory-Starved Queries: Forced Grants',
details =
- N'dm_exec_query_resource_semaphores has ' +
+ N'dm_exec_query_resource_semaphores reports ' +
CONVERT(nvarchar(10), MAX(ders.forced_grant_count)) +
- N' forced memory grants. ' +
- N'Queries are being forced to run with less memory than requested, which can cause spills to tempdb and poor performance.',
+ N' forced memory grants since startup. Queries ran with less memory than they asked for and spilled to tempdb. ' +
+ N'Review oversized grants (large sorts and hashes), query tuning, and max server memory.',
url = N'https://erikdarling.com/sp_perfcheck/#MemoryStarved'
FROM sys.dm_exec_query_resource_semaphores AS ders
WHERE ders.forced_grant_count > 0
@@ -999,14 +1006,21 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
SELECT
check_id = 4103,
- priority = 20, /* High: queries can't get memory */
+ priority =
+ CASE
+ WHEN MAX(ders.timeout_error_count) > 100
+ THEN 20 /* High: queries repeatedly failing to get memory */
+ WHEN MAX(ders.timeout_error_count) > 10
+ THEN 30 /* Medium */
+ ELSE 40 /* Low: a few since startup, likely transient */
+ END,
category = N'Memory Pressure',
finding = N'Memory-Starved Queries: Grant Timeouts',
details =
- N'dm_exec_query_resource_semaphores has ' +
+ N'dm_exec_query_resource_semaphores reports ' +
CONVERT(nvarchar(10), MAX(ders.timeout_error_count)) +
- N' memory grant timeouts. ' +
- N'Queries are waiting for memory for a long time and giving up.',
+ N' memory grant timeouts since startup. Queries waited a long time for a memory grant and gave up (error 8645). ' +
+ N'Review oversized grants, RESOURCE_SEMAPHORE waits, and max server memory.',
url = N'https://erikdarling.com/sp_perfcheck/#MemoryStarved'
FROM sys.dm_exec_query_resource_semaphores AS ders
WHERE ders.timeout_error_count > 0
@@ -1403,6 +1417,60 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
N''
);
END;
+
+ /*
+ Flag notable global trace flags with interpretation. The complete list
+ is in #server_info; here we call out the ones that change behavior
+ server-wide or are redundant on modern versions, with severity by how
+ much they matter. Benign flags (backup-message suppression, lightweight
+ profiling, deadlock logging) are left in the server-info list only.
+ */
+ INSERT INTO
+ #results
+ (
+ check_id,
+ priority,
+ category,
+ finding,
+ object_name,
+ details,
+ url
+ )
+ SELECT
+ check_id = 1012,
+ priority =
+ CASE tf.trace_flag
+ WHEN 1211 THEN 20 /* High: disables lock escalation entirely */
+ WHEN 3608 THEN 20 /* High: startup-only flag set globally */
+ WHEN 3609 THEN 20 /* High: startup-only flag set globally */
+ WHEN 1224 THEN 30 /* Medium */
+ WHEN 834 THEN 30 /* Medium */
+ ELSE 40 /* Low: notable but not dangerous */
+ END,
+ category = N'Server Configuration',
+ finding = N'Notable Global Trace Flag',
+ object_name = N'TF ' + CONVERT(nvarchar(10), tf.trace_flag),
+ details =
+ N'Global trace flag ' +
+ CONVERT(nvarchar(10), tf.trace_flag) +
+ N' is enabled. ' +
+ CASE tf.trace_flag
+ WHEN 1211 THEN N'Disables lock escalation entirely, which can bloat lock memory and hurt concurrency. Almost never recommended; prefer 1224 if you truly must.'
+ WHEN 1224 THEN N'Disables count-based lock escalation (escalation still happens under memory pressure). Rarely necessary.'
+ WHEN 3608 THEN N'A startup-only flag (recover master only) that should not be set on a running production server.'
+ WHEN 3609 THEN N'A startup-only flag (skip tempdb creation) that should not be set on a running production server.'
+ WHEN 834 THEN N'Uses large-page allocations for the buffer pool. Can slow or block startup and interacts badly with columnstore; use deliberately.'
+ WHEN 4199 THEN N'Enables all query optimizer hotfixes globally, which can change plans server-wide. On 2016+ prefer the database-scoped QUERY_OPTIMIZER_HOTFIXES option.'
+ WHEN 8048 THEN N'Partitions memory objects per CPU to reduce spinlock contention. Only relevant on high-core NUMA servers.'
+ WHEN 1117 THEN N'Grows all files in a filegroup together. Redundant for tempdb on 2016+, where this is already the default.'
+ WHEN 1118 THEN N'Forces full-extent allocation. Redundant on 2016+, where this is already the default for tempdb.'
+ WHEN 2371 THEN N'Lowers the auto-update-statistics threshold. Redundant on 2016+ at compatibility level 130+.'
+ ELSE N'Review whether it is still needed.'
+ END,
+ url = N'https://erikdarling.com/sp_perfcheck/#TraceFlags'
+ FROM #trace_flags AS tf
+ WHERE tf.global = 1
+ AND tf.trace_flag IN (1211, 1224, 3608, 3609, 834, 4199, 8048, 1117, 1118, 2371);
END;
/* Memory information - works on all platforms */
@@ -2068,7 +2136,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#wait_stats.wait_time_percent_of_uptime =
(wait_time_ms * 100.0 / NULLIF(@uptime_ms, 0));
- /* Add only waits that represent >=10% of server uptime */
+ /*
+ Surface the significant waits as a health-check diagnosis rather than a
+ flat list. Name each finding by what the wait means for the server
+ (Storage / Lock / Memory / TempDB / CPU / Log / Parallelism ...), put the
+ specific wait type and its plain-English meaning in object_name, and
+ calibrate severity by category: resource-pressure waits (locking, memory,
+ storage, tempdb, log, CPU) earn High by dominating uptime OR by long
+ average waits; parallelism is usually a cost threshold / MAXDOP symptom,
+ so it takes a very high share just to reach Medium; everything else stays
+ Low. SLEEP_TASK is collected for context but not surfaced as a finding.
+ */
INSERT INTO
#results
(
@@ -2080,41 +2158,85 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
details,
url
)
- SELECT TOP (10) /* Limit to top 10 most significant waits */
+ SELECT TOP (10) /* the ten most significant waits */
6001,
priority =
CASE
- WHEN ws.wait_time_percent_of_uptime > 100
- THEN 20 /* High: >100% of uptime */
- WHEN ws.wait_time_percent_of_uptime > 75
- THEN 20 /* High: >75% of uptime */
- WHEN ws.wait_time_percent_of_uptime >= 50
- THEN 30 /* Medium: >=50% of uptime */
- ELSE 40 /* Low: >=10% of uptime */
+ WHEN ws.category IN (N'Locking', N'Memory', N'I/O', N'TempDB Contention', N'Transaction Log', N'CPU')
+ THEN
+ CASE
+ WHEN ws.wait_time_percent_of_uptime >= 50.0
+ OR ws.avg_wait_ms >= 1000.0
+ THEN 20 /* High: dominates uptime, or each wait is very long */
+ WHEN ws.wait_time_percent_of_uptime >= 20.0
+ OR ws.avg_wait_ms >= 250.0
+ THEN 30 /* Medium */
+ ELSE 40 /* Low */
+ END
+ WHEN ws.category = N'Parallelism'
+ THEN
+ CASE
+ WHEN ws.wait_time_percent_of_uptime >= 100.0
+ THEN 30 /* Medium at most: usually a cost threshold / MAXDOP symptom */
+ ELSE 40 /* Low */
+ END
+ ELSE 40 /* Low: network, query execution, stats, AG, throttling, other */
END,
category = N'Wait Statistics',
- finding = N'High Impact Wait Type',
+ finding =
+ CASE ws.category
+ WHEN N'I/O' THEN N'Storage-Related Waits'
+ WHEN N'Memory' THEN N'Memory-Related Waits'
+ WHEN N'Parallelism' THEN N'Parallelism Waits'
+ WHEN N'CPU' THEN N'CPU / Scheduling Waits'
+ WHEN N'TempDB Contention' THEN N'TempDB Contention Waits'
+ WHEN N'Locking' THEN N'Lock / Blocking Waits'
+ WHEN N'Transaction Log' THEN N'Transaction Log Waits'
+ WHEN N'Query Execution' THEN N'Query Execution Waits'
+ WHEN N'Network' THEN N'Network / Client Waits'
+ WHEN N'Availability Groups' THEN N'Availability Group Waits'
+ WHEN N'Azure SQL Throttling' THEN N'Azure SQL Throttling Waits'
+ WHEN N'Index Management' THEN N'Index Maintenance Waits'
+ WHEN N'Statistics' THEN N'Statistics Update Waits'
+ ELSE N'Other Significant Waits'
+ END,
object_name =
ws.wait_type +
N' (' +
- ws.category +
+ ws.description +
N')',
details =
- N'Wait type: ' +
+ N'Wait type ' +
ws.wait_type +
- N' represents ' +
+ N' accounts for ' +
CONVERT(nvarchar(10), CONVERT(decimal(10, 2), ws.wait_time_percent_of_uptime)) +
N'% of server uptime (' +
- CONVERT(nvarchar(20), CONVERT(decimal(10, 2), ws.wait_time_minutes)) +
- N' minutes). ' +
- N'Average wait: ' +
+ CONVERT(nvarchar(20), CONVERT(decimal(10, 2), ws.wait_time_hours)) +
+ N' hours), averaging ' +
CONVERT(nvarchar(10), CONVERT(decimal(10, 2), ws.avg_wait_ms)) +
N' ms per wait. ' +
- N'Description: ' +
- ws.description,
+ CASE ws.category
+ WHEN N'Locking'
+ THEN N'Time lost to blocking. Investigate long-running transactions and blocking chains; sp_PressureDetector shows live blockers.'
+ WHEN N'Memory'
+ THEN N'Queries are waiting on memory. Review max server memory and query memory grants; oversized grants force RESOURCE_SEMAPHORE waits.'
+ WHEN N'I/O'
+ THEN N'Reads are waiting on storage. Check the per-file latency findings and whether the working set fits in the buffer pool.'
+ WHEN N'TempDB Contention'
+ THEN N'Allocation-page contention in tempdb. Use equal-sized tempdb data files, and memory-optimized tempdb metadata on 2019+.'
+ WHEN N'Transaction Log'
+ THEN N'Commits are waiting on the log. Check log storage latency, transaction sizes, and log backup frequency.'
+ WHEN N'CPU'
+ THEN N'Scheduling pressure. Review parallelism settings and plan quality; THREADPOOL specifically means worker-thread exhaustion.'
+ WHEN N'Parallelism'
+ THEN N'Usually a cost threshold for parallelism / MAXDOP tuning issue rather than a problem in itself.'
+ WHEN N'Network'
+ THEN N'Usually client-side: the application consuming results slowly or a slow network, not the server.'
+ ELSE N'Description: ' + ws.description + N'.'
+ END,
url = N'https://erikdarling.com/sp_perfcheck/#WaitStats'
FROM #wait_stats AS ws
- WHERE ws.wait_time_percent_of_uptime >= 10.0 /* Only include waits that are at least 10% of uptime */
+ WHERE ws.wait_time_percent_of_uptime >= 10.0
AND ws.wait_type <> N'SLEEP_TASK'
ORDER BY
ws.wait_time_percent_of_uptime DESC;
@@ -2840,9 +2962,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 3001,
priority =
CASE
- WHEN i.avg_read_latency_ms > @slow_read_ms * 2
- THEN 20 /* High: >1000ms is severe */
- ELSE 30 /* Medium: >500ms is significant */
+ WHEN i.avg_read_latency_ms > @slow_read_ms * 5
+ THEN 20 /* High: >100ms average reads (5x threshold) is bad storage */
+ ELSE 30 /* Medium: over the 20ms threshold, worth investigating */
END,
category = N'Storage Performance',
finding = N'Slow Read Latency',
@@ -2902,9 +3024,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 3002,
priority =
CASE
- WHEN i.avg_write_latency_ms > @slow_write_ms * 2
- THEN 20 /* High: >1000ms is severe */
- ELSE 30 /* Medium: >500ms is significant */
+ WHEN i.avg_write_latency_ms > @slow_write_ms * 5
+ THEN 20 /* High: >100ms average writes (5x threshold) is bad storage */
+ ELSE 30 /* Medium: over the 20ms threshold, worth investigating */
END,
category = N'Storage Performance',
finding = N'Slow Write Latency',
From 53ca04038cda3cc0470e1c3a166dc5f80eea9c4b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 10:38:45 -0400
Subject: [PATCH 43/60] sp_PerfCheck harness: force OFFLINE with ROLLBACK
IMMEDIATE
The inaccessible-database tests took a database OFFLINE without ROLLBACK
IMMEDIATE, so any open connection made the ALTER wait indefinitely and the
suite hung (300s subprocess timeout). Force it, so the transition is
immediate and the test can't hang in CI.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/tests/run_tests.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py
index 9b132fc2..8d1d605b 100644
--- a/sp_PerfCheck/tests/run_tests.py
+++ b/sp_PerfCheck/tests/run_tests.py
@@ -553,7 +553,7 @@ def inaccessible_database_tests(server, password, R):
# metadata read sees a closed database.
_sqlcmd(server, password, "ALTER DATABASE [%s] SET AUTO_CLOSE ON;" % db)
_sqlcmd(server, password,
- "ALTER DATABASE [%s] SET OFFLINE; "
+ "ALTER DATABASE [%s] SET OFFLINE WITH ROLLBACK IMMEDIATE; "
"ALTER DATABASE [%s] SET ONLINE;" % (db, db))
out, err = run_perfcheck(server, password, "@database_name = N'%s'" % db)
errs = find_sql_errors(out) + find_sql_errors(err)
@@ -561,7 +561,7 @@ def inaccessible_database_tests(server, password, R):
not errs, str(errs))
# OFFLINE is the more severe case, and the one a whole-instance run hits.
- _sqlcmd(server, password, "ALTER DATABASE [%s] SET OFFLINE;" % db)
+ _sqlcmd(server, password, "ALTER DATABASE [%s] SET OFFLINE WITH ROLLBACK IMMEDIATE;" % db)
out, err = run_perfcheck(server, password, "@database_name = N'%s'" % db)
errs = find_sql_errors(out) + find_sql_errors(err)
R.check(grp, "OFFLINE database scoped run does not crash (Msg 515)",
From f7b678802de787237342c4b96f95b027b712285f Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 10:38:45 -0400
Subject: [PATCH 44/60] sp_PerfCheck Phase 3: promote calibrated thresholds to
parameters
The values calibrated in Phase 2 were still hardcoded. Expose the tunable
ones as parameters (defaults = the Phase 2 values, so default behavior is
unchanged), letting a caller adjust to their environment:
@slow_read_ms / @slow_write_ms storage latency (ms)
@significant_wait_threshold_pct floor for a wait to be reported
@wait_high_pct / @wait_medium_pct resource-wait severity bands
@memory_grant_warning / _critical forced-grant severity bands
Each defaults NULL or negative back to its documented value, so a caller can
override just the ones they care about. Help text (description / valid inputs
/ defaults) covers all seven. The secondary avg-ms wait bands and the grant-
timeout bands stay internal for now. Compiles on all five versions; the
assertion harness passes 39/39.
Co-Authored-By: Claude Opus 4.8
---
sp_PerfCheck/sp_PerfCheck.sql | 63 ++++++++++++++++++++++++++++++-----
1 file changed, 54 insertions(+), 9 deletions(-)
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index c8c64817..2da0e013 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -50,6 +50,13 @@ ALTER PROCEDURE
dbo.sp_PerfCheck
(
@database_name sysname = NULL, /* Database to check, NULL for all user databases */
+ @slow_read_ms decimal(10, 2) = 20.0, /* Flag data-file reads slower than this (ms); High at 5x */
+ @slow_write_ms decimal(10, 2) = 20.0, /* Flag data-file writes slower than this (ms); High at 5x */
+ @significant_wait_threshold_pct decimal(5, 2) = 10.0, /* Minimum % of uptime for a wait to be reported */
+ @wait_high_pct decimal(5, 2) = 50.0, /* Resource wait at/above this % of uptime is High */
+ @wait_medium_pct decimal(5, 2) = 20.0, /* Resource wait at/above this % of uptime is Medium */
+ @memory_grant_warning integer = 100, /* Forced grants at/above this count are Medium */
+ @memory_grant_critical integer = 10000, /* Forced grants at/above this count are High */
@help bit = 0, /*For helpfulness*/
@debug bit = 0, /* Print diagnostic messages */
@version varchar(30) = NULL OUTPUT, /* Returns version */
@@ -103,6 +110,13 @@ BEGIN
WHEN N'@debug' THEN 'prints debug information during execution'
WHEN N'@version' THEN 'returns the version number of the procedure'
WHEN N'@version_date' THEN 'returns the date this version was released'
+ WHEN N'@slow_read_ms' THEN 'flag data-file reads slower than this many ms (High at 5x)'
+ WHEN N'@slow_write_ms' THEN 'flag data-file writes slower than this many ms (High at 5x)'
+ WHEN N'@significant_wait_threshold_pct' THEN 'minimum percent of uptime for a wait to be reported'
+ WHEN N'@wait_high_pct' THEN 'a resource wait at or above this percent of uptime is High priority'
+ WHEN N'@wait_medium_pct' THEN 'a resource wait at or above this percent of uptime is Medium priority'
+ WHEN N'@memory_grant_warning' THEN 'forced memory grants at or above this cumulative count are Medium'
+ WHEN N'@memory_grant_critical' THEN 'forced memory grants at or above this cumulative count are High'
ELSE NULL
END,
valid_inputs =
@@ -113,6 +127,13 @@ BEGIN
WHEN N'@debug' THEN '0 or 1'
WHEN N'@version' THEN 'OUTPUT parameter'
WHEN N'@version_date' THEN 'OUTPUT parameter'
+ WHEN N'@slow_read_ms' THEN 'any positive number of milliseconds'
+ WHEN N'@slow_write_ms' THEN 'any positive number of milliseconds'
+ WHEN N'@significant_wait_threshold_pct' THEN 'any positive percentage'
+ WHEN N'@wait_high_pct' THEN 'any positive percentage'
+ WHEN N'@wait_medium_pct' THEN 'any positive percentage'
+ WHEN N'@memory_grant_warning' THEN 'any positive integer'
+ WHEN N'@memory_grant_critical' THEN 'any positive integer'
ELSE NULL
END,
defaults =
@@ -123,6 +144,13 @@ BEGIN
WHEN N'@debug' THEN 'false'
WHEN N'@version' THEN 'NULL'
WHEN N'@version_date' THEN 'NULL'
+ WHEN N'@slow_read_ms' THEN '20.0'
+ WHEN N'@slow_write_ms' THEN '20.0'
+ WHEN N'@significant_wait_threshold_pct' THEN '10.0'
+ WHEN N'@wait_high_pct' THEN '50.0'
+ WHEN N'@wait_medium_pct' THEN '20.0'
+ WHEN N'@memory_grant_warning' THEN '100'
+ WHEN N'@memory_grant_critical' THEN '10000'
ELSE NULL
END
FROM sys.all_parameters AS ap
@@ -167,6 +195,27 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
RETURN;
END;
+ /*
+ Default any tuning threshold left NULL or negative back to its documented
+ value, so a caller can override only the ones they care about. A CASE on
+ ">= 0" catches both NULL (UNKNOWN) and negative in one shot.
+ */
+ SELECT
+ @slow_read_ms =
+ CASE WHEN @slow_read_ms >= 0 THEN @slow_read_ms ELSE 20.0 END,
+ @slow_write_ms =
+ CASE WHEN @slow_write_ms >= 0 THEN @slow_write_ms ELSE 20.0 END,
+ @significant_wait_threshold_pct =
+ CASE WHEN @significant_wait_threshold_pct >= 0 THEN @significant_wait_threshold_pct ELSE 10.0 END,
+ @wait_high_pct =
+ CASE WHEN @wait_high_pct >= 0 THEN @wait_high_pct ELSE 50.0 END,
+ @wait_medium_pct =
+ CASE WHEN @wait_medium_pct >= 0 THEN @wait_medium_pct ELSE 20.0 END,
+ @memory_grant_warning =
+ CASE WHEN @memory_grant_warning >= 0 THEN @memory_grant_warning ELSE 100 END,
+ @memory_grant_critical =
+ CASE WHEN @memory_grant_critical >= 0 THEN @memory_grant_critical ELSE 10000 END;
+
/*
Variable Declarations
*/
@@ -282,9 +331,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@size_difference_pct decimal(18, 2),
@has_percent_growth bit,
@has_fixed_growth bit,
- /* Storage performance variables */
- @slow_read_ms decimal(10, 2) = 20.0, /* Slow read threshold (ms); data-file reads should be under 20ms */
- @slow_write_ms decimal(10, 2) = 20.0, /* Slow write threshold (ms); data-file writes should be under 20ms */
/* Set threshold for "slow" autogrowth (in ms) */
@slow_autogrow_ms integer = 1000, /* 1 second */
@trace_path nvarchar(260),
@@ -292,7 +338,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/* Determine total waits, uptime, and significant waits */
@total_waits bigint,
@uptime_ms bigint,
- @significant_wait_threshold_pct decimal(5, 2) = 10.0, /* Only waits above 10% */
@significant_wait_threshold_avg decimal(10, 2) = 10.0, /* Or avg wait time > 10ms */
/* Threshold settings for stolen memory alert */
@buffer_pool_size_gb decimal(38, 2),
@@ -974,9 +1019,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
check_id = 4101,
priority =
CASE
- WHEN MAX(ders.forced_grant_count) > 10000
+ WHEN MAX(ders.forced_grant_count) >= @memory_grant_critical
THEN 20 /* High: heavy, sustained memory pressure */
- WHEN MAX(ders.forced_grant_count) > 100
+ WHEN MAX(ders.forced_grant_count) >= @memory_grant_warning
THEN 30 /* Medium */
ELSE 40 /* Low: a handful since startup, likely transient */
END,
@@ -2165,10 +2210,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHEN ws.category IN (N'Locking', N'Memory', N'I/O', N'TempDB Contention', N'Transaction Log', N'CPU')
THEN
CASE
- WHEN ws.wait_time_percent_of_uptime >= 50.0
+ WHEN ws.wait_time_percent_of_uptime >= @wait_high_pct
OR ws.avg_wait_ms >= 1000.0
THEN 20 /* High: dominates uptime, or each wait is very long */
- WHEN ws.wait_time_percent_of_uptime >= 20.0
+ WHEN ws.wait_time_percent_of_uptime >= @wait_medium_pct
OR ws.avg_wait_ms >= 250.0
THEN 30 /* Medium */
ELSE 40 /* Low */
@@ -2236,7 +2281,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
END,
url = N'https://erikdarling.com/sp_perfcheck/#WaitStats'
FROM #wait_stats AS ws
- WHERE ws.wait_time_percent_of_uptime >= 10.0
+ WHERE ws.wait_time_percent_of_uptime >= @significant_wait_threshold_pct
AND ws.wait_type <> N'SLEEP_TASK'
ORDER BY
ws.wait_time_percent_of_uptime DESC;
From 62bd34532926237d45132e2f08d57841a453d8a7 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 18 Jul 2026 17:43:39 -0400
Subject: [PATCH 45/60] tests: add sp_QuickieStore assertion harness and wire
it into CI
sp_QuickieStore had no automated coverage at all, and it is the proc where
compiling proves least: ~16,000 lines that assemble a large dynamic SQL
statement whose shape changes with almost every one of its 59 parameters.
@sort_order alone accepts 39 values, each building a different ORDER BY (the
wait sorts additionally join query_store_wait_stats), and @wait_filter /
@execution_type_desc / @query_type / @expert_mode / @format_output each rewrite
the statement again. A combination nobody has run assembles SQL that fails only
at EXECUTION.
So the harness is a parameter matrix that executes what each combination built:
all 39 sort orders, 9 wait filters, 4 execution types, 4 query types, and the
expert/format grid. On top of that, bidirectional assertions prove the filters
actually filter rather than being ignored (@query_text_search on a known string
vs nonsense, @query_type partitioning proc from ad hoc, @top, @execution_count
set impossibly high), each absence paired with a completion check so an errored
run cannot pass vacuously. 134 assertions; green on 2017, 2022 and 2025.
It builds its own Query Store scratch database with a varied workload and drops
it in a finally block. Nothing outside that database is touched.
@debug = 1 is exercised against a database with no Query Store data on purpose:
with data, debug mode returns the generated SQL as an XML column, and go-sqlcmd
renders XML columns slowly enough that the server parks in ASYNC_NETWORK_IO and
it looks exactly like a hang. That is a client limitation - the same run is
instant in SSMS and a non-debug run at full width returns in under a second.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 13 +
sp_QuickieStore/tests/README.md | 60 ++++
sp_QuickieStore/tests/run_tests.py | 432 +++++++++++++++++++++++++++++
3 files changed, 505 insertions(+)
create mode 100644 sp_QuickieStore/tests/README.md
create mode 100644 sp_QuickieStore/tests/run_tests.py
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index 1d729198..09f074d0 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -200,3 +200,16 @@ jobs:
SQLCMD_CONN_ARGS: "-C -N disable"
run: |
python3 sp_QueryReproBuilder/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
+
+ # sp_QuickieStore is ~16k lines of dynamic SQL whose shape changes with
+ # almost every parameter, so this is a parameter matrix: all 39 sort
+ # orders, the wait filters, execution types, query types and the
+ # expert/format grid, each of which executes the statement it just built.
+ # It creates and drops its own Query Store scratch database.
+ - name: sp_QuickieStore assertion harness
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ SQLCMD_BIN: /usr/local/bin/sqlcmd
+ SQLCMD_CONN_ARGS: "-C -N disable"
+ run: |
+ python3 sp_QuickieStore/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
diff --git a/sp_QuickieStore/tests/README.md b/sp_QuickieStore/tests/README.md
new file mode 100644
index 00000000..6a71b1cf
--- /dev/null
+++ b/sp_QuickieStore/tests/README.md
@@ -0,0 +1,60 @@
+# sp_QuickieStore Tests
+
+**Run before and after any change to `sp_QuickieStore.sql`.** Compiling proves
+very little here: the procedure is ~16,000 lines that assemble a large dynamic
+SQL statement whose *shape* changes with almost every one of its 59 parameters.
+The statement that breaks is built at run time from string fragments, so it only
+fails when it executes.
+
+## Automated
+
+| Script | What it does |
+| --- | --- |
+| `run_tests.py` | Builds a Query Store scratch database, then runs a parameter matrix asserting each combination executes cleanly and reaches completion, plus bidirectional filter checks. 134 assertions. |
+
+```
+cd sp_QuickieStore/tests
+python run_tests.py --server SQL2022
+```
+
+Takes `--server` and `--password` (default `SQL2022` / the standard local sa
+password). Expect `134`.
+
+## What it actually covers
+
+The matrix is the point. Every axis below rewrites the generated statement, and
+each run *executes* what it built:
+
+- **All 39 `@sort_order` values** — the highest-value axis. Each builds a
+ different `ORDER BY`, and the wait sorts additionally join
+ `query_store_wait_stats`.
+- **9 `@wait_filter` values**, **4 `@execution_type_desc`**, **4 `@query_type`**.
+- The **`@expert_mode` x `@format_output` grid**, which rewrites the column list,
+ plus several sort orders combined with expert mode.
+
+On top of that, **bidirectional** assertions prove the filters actually filter
+rather than being silently ignored — `@query_text_search` on a known string vs
+nonsense, `@query_type` partitioning proc from ad hoc, `@top`, and
+`@execution_count` set impossibly high. Every absence assertion is paired with a
+completion check so an errored or empty run cannot pass vacuously.
+
+## Fixture
+
+The harness creates its own `quickiestore_test` database with Query Store on,
+runs a small varied workload (ad hoc queries at different costs plus a stored
+procedure, so `@query_type` has both kinds to separate), flushes Query Store, and
+drops the database in a `finally` block that runs even if assertions fail.
+Nothing outside that database is touched.
+
+## Known client limitation: `@debug = 1`
+
+`@debug = 1` is exercised against a database with **no** Query Store data on
+purpose. With data, debug mode returns the generated SQL as an **XML column** (so
+it is clickable in SSMS), and **go-sqlcmd renders XML columns pathologically
+slowly** — capturing the output takes minutes and looks exactly like the
+procedure hanging, with the server parked in `ASYNC_NETWORK_IO` waiting for the
+client to drain the result set.
+
+This is a client limitation, not a procedure defect: the same run is instant in
+SSMS, and a non-debug run against the same populated database returns at full
+width in well under a second. Do not "fix" this by widening timeouts.
diff --git a/sp_QuickieStore/tests/run_tests.py b/sp_QuickieStore/tests/run_tests.py
new file mode 100644
index 00000000..898335f5
--- /dev/null
+++ b/sp_QuickieStore/tests/run_tests.py
@@ -0,0 +1,432 @@
+"""
+sp_QuickieStore assertion test harness
+======================================
+sp_QuickieStore is ~16,000 lines that assemble a large dynamic SQL statement
+whose SHAPE changes with almost every one of its 59 parameters: @sort_order
+alone accepts 35+ values, each producing a different ORDER BY and column set,
+and @wait_filter / @execution_type_desc / @query_type / @expert_mode /
+@format_output each rewrite the statement again.
+
+That is where this procedure's bugs live. A parameter combination nobody has
+run assembles SQL that is malformed or references a column that is not in that
+shape, and it fails only at EXECUTION -- compiling the procedure proves nothing,
+because the statement that breaks is built at runtime from string fragments.
+
+So the core of this harness is a parameter matrix: run the procedure across
+every @sort_order, every @wait_filter, every @execution_type_desc and
+@query_type, in both @expert_mode and @format_output states, and assert each
+run executes cleanly and reaches completion. Every run executes the dynamic SQL
+it just built, which is the only way to catch this class of defect.
+
+On top of that, a few BIDIRECTIONAL filter assertions (finding present when the
+filter should match, absent when it should not) prove the filters actually
+filter rather than being silently ignored.
+
+Fixture: the harness builds its own scratch database with Query Store enabled,
+runs a small varied workload (ad hoc queries at different costs plus a stored
+procedure, so @query_type has both kinds to separate), flushes Query Store, and
+drops the database at the end. Nothing outside that database is touched.
+
+Usage:
+ python run_tests.py [--server SQL2022] [--password L!nt0044]
+
+Exits 1 if any assertion fails.
+"""
+
+import argparse
+import os
+import re
+import shlex
+import subprocess
+import sys
+
+TEST_DB = "quickiestore_test"
+
+# The footer result set the procedure always emits last. Its presence proves the
+# run reached completion rather than dying midway, and is the positive control
+# for every "no rows" / absence assertion.
+DONE_MARKER = "brought to you by darling data!"
+
+# Every @sort_order the procedure documents. Each one builds a different
+# ORDER BY (and for the wait sorts, joins query_store_wait_stats), so this is
+# the highest-value axis in the matrix.
+SORT_ORDERS = [
+ "cpu", "logical reads", "physical reads", "writes", "duration", "memory",
+ "tempdb", "executions", "recent", "plan count by hashes",
+ "cpu waits", "lock waits", "locks waits", "latch waits", "latches waits",
+ "buffer latch waits", "buffer latches waits", "buffer io waits",
+ "log waits", "log io waits", "network waits", "network io waits",
+ "parallel waits", "parallelism waits", "memory waits", "total waits",
+ "rows",
+ "total cpu", "total logical reads", "total physical reads", "total writes",
+ "total duration", "total memory", "total tempdb", "total rows",
+ # the avg/average prefixes the help text says are also accepted
+ "avg cpu", "average duration", "avg tempdb",
+]
+
+WAIT_FILTERS = [
+ "cpu", "lock", "latch", "buffer latch", "buffer io", "log io",
+ "network io", "parallelism", "memory",
+]
+
+EXECUTION_TYPES = ["regular", "aborted", "exception", "failed"]
+
+QUERY_TYPES = ["ad hoc", "adhoc", "proc", "procedure"]
+
+
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment
+ so one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to
+ 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the
+ go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ container's self-signed cert and disable encryption, which the modern Go
+ TLS stack needs to connect to the SQL Server 2017 container."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
+def _sqlcmd(server, password, sql, database="master", timeout=300):
+ """Run a batch and return (stdout, stderr) decoded as UTF-8.
+
+ -y 200 truncates variable-length columns in the OUTPUT, which keeps the
+ captured text small: the main result set carries query_plan as full
+ ShowPlanXML and we only ever need enough of a column to detect errors and
+ count rows, never the whole plan.
+ """
+ cmd = _sqlcmd_prefix() + [
+ "-S", server, "-U", "sa", "-P", password,
+ "-d", database,
+ "-W", # trim trailing spaces
+ "-w", "65535", # do not wrap wide rows
+ "-y", "200", # truncate wide columns (plan XML) so rendering is fast
+ "-s", "\t", # tab delimiter
+ "-Q", sql,
+ ]
+ r = subprocess.run(cmd, capture_output=True, timeout=timeout)
+ return ((r.stdout or b"").decode("utf-8", errors="replace"),
+ (r.stderr or b"").decode("utf-8", errors="replace"))
+
+
+def _esc(s):
+ """Escape a T-SQL single-quoted literal."""
+ return s.replace("'", "''")
+
+
+def find_sql_errors(text):
+ """Severity 16+ errors from either stream. go-sqlcmd reports SQL errors on
+ stdout, so both have to be checked. Msg 4060/911 ("Cannot open database")
+ are only Level 11 but mean the batch never ran, so catch those too."""
+ if not text:
+ return []
+ pattern = r"Msg (?:\d+, Level 1[6-9]|4060|911)[^\n]*"
+ return re.findall(pattern, text)
+
+
+def run_qs(server, password, extra="", timeout=300):
+ """Run sp_QuickieStore against the scratch database, always naming it
+ explicitly, and return (stdout, combined-for-error-scanning)."""
+ sql = ("SET NOCOUNT ON; EXECUTE dbo.sp_QuickieStore "
+ "@database_name = '%s'%s;" % (_esc(TEST_DB), extra))
+ out, err = _sqlcmd(server, password, sql, timeout=timeout)
+ return out, out + "\n" + err
+
+
+def completed(stdout):
+ """True if the procedure reached its final footer result set."""
+ return DONE_MARKER in stdout
+
+
+def result_rows(stdout):
+ """Count of main result-set rows (each begins with the source column)."""
+ return sum(1 for line in stdout.splitlines()
+ if line.startswith("runtime_stats") or line.startswith("plan_forcing"))
+
+
+class Results:
+ def __init__(self):
+ self.items = []
+
+ def check(self, group, name, condition, detail=""):
+ self.items.append({"group": group, "name": name,
+ "passed": bool(condition), "detail": detail})
+
+ @property
+ def passed(self):
+ return sum(1 for r in self.items if r["passed"])
+
+ @property
+ def failed(self):
+ return sum(1 for r in self.items if not r["passed"])
+
+
+FIXTURE_SQL = """
+SET NOCOUNT ON;
+IF DB_ID(N'{db}') IS NOT NULL
+BEGIN
+ ALTER DATABASE {db} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE {db};
+END;
+CREATE DATABASE {db};
+ALTER DATABASE {db} SET QUERY_STORE = ON
+ (OPERATION_MODE = READ_WRITE, DATA_FLUSH_INTERVAL_SECONDS = 60,
+ INTERVAL_LENGTH_MINUTES = 1, QUERY_CAPTURE_MODE = ALL);
+""".format(db=TEST_DB)
+
+# Second batch: runs inside the scratch database. Kept separate because it needs
+# the database to exist and to be the connection context.
+WORKLOAD_SQL = """
+SET NOCOUNT ON;
+CREATE TABLE dbo.t
+(
+ id integer NOT NULL IDENTITY PRIMARY KEY,
+ a integer NOT NULL,
+ b varchar(100) NOT NULL
+);
+
+INSERT dbo.t WITH (TABLOCK) (a, b)
+SELECT TOP (50000) ac1.column_id, REPLICATE('x', 50)
+FROM sys.all_columns AS ac1 CROSS JOIN sys.all_columns AS ac2;
+
+EXECUTE (N'CREATE PROCEDURE dbo.qs_test_proc AS BEGIN SELECT c = COUNT_BIG(*) FROM dbo.t WHERE a % 7 = 0; END;');
+
+DECLARE @i integer = 0, @c bigint;
+WHILE @i < 12
+BEGIN
+ SELECT @c = COUNT_BIG(*) FROM dbo.t WHERE a > 5;
+ SELECT @c = SUM(CONVERT(bigint, a)) FROM dbo.t WHERE b LIKE 'x%';
+ SELECT @c = COUNT_BIG(*) FROM dbo.t AS t1 JOIN dbo.t AS t2 ON t2.a = t1.a WHERE t1.id < 400;
+ EXECUTE dbo.qs_test_proc;
+ SET @i += 1;
+END;
+
+EXECUTE sys.sp_query_store_flush_db;
+"""
+
+CLEANUP_SQL = """
+SET NOCOUNT ON;
+IF DB_ID(N'{db}') IS NOT NULL
+BEGIN
+ ALTER DATABASE {db} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
+ DROP DATABASE {db};
+END;
+""".format(db=TEST_DB)
+
+
+def build_fixture(server, password, R):
+ out, err = _sqlcmd(server, password, FIXTURE_SQL)
+ errs = find_sql_errors(out + "\n" + err)
+ R.check("Fixture", "scratch database created with Query Store on",
+ not errs, str(errs))
+ if errs:
+ return False
+
+ out, err = _sqlcmd(server, password, WORKLOAD_SQL, database=TEST_DB,
+ timeout=600)
+ errs = find_sql_errors(out + "\n" + err)
+ R.check("Fixture", "workload ran and Query Store flushed", not errs, str(errs))
+ if errs:
+ return False
+
+ out, _ = _sqlcmd(server, password,
+ "SET NOCOUNT ON; SELECT COUNT_BIG(*) FROM sys.query_store_query;",
+ database=TEST_DB)
+ captured = any(line.strip().isdigit() and int(line.strip()) > 0
+ for line in out.splitlines())
+ R.check("Fixture", "Query Store captured queries to analyze",
+ captured, "no queries captured; the matrix would pass vacuously")
+ return captured
+
+
+def smoke_tests(server, password, R):
+ out, combined = run_qs(server, password)
+ R.check("Smoke", "default run: no severe SQL error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("Smoke", "default run: reached completion",
+ completed(out), "footer result set missing")
+ R.check("Smoke", "default run: returned query rows",
+ result_rows(out) > 0, "no result rows from a populated Query Store")
+
+ out, combined = run_qs(server, password, ", @help = 1")
+ R.check("Smoke", "@help = 1: no severe SQL error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+
+ # @debug is exercised against a database with no Query Store data on
+ # purpose. With data, debug mode returns the generated SQL as an XML column
+ # (so it is clickable in SSMS), and go-sqlcmd renders XML columns so slowly
+ # that capturing the output takes minutes and looks like a hang -- the
+ # server parks in ASYNC_NETWORK_IO waiting for the client to drain it. SSMS
+ # handles it fine; this is a client limitation, not a procedure defect, so
+ # we exercise the debug code path where it is capturable rather than skip it.
+ sql = "SET NOCOUNT ON; EXECUTE dbo.sp_QuickieStore @database_name = 'master', @debug = 1;"
+ out, err = _sqlcmd(server, password, sql)
+ R.check("Smoke", "@debug = 1: no severe SQL error",
+ not find_sql_errors(out + "\n" + err),
+ str(find_sql_errors(out + "\n" + err)))
+
+
+def sort_order_matrix(server, password, R):
+ """Every @sort_order, each of which builds a different ORDER BY. This is the
+ highest-value axis: a bad sort order yields SQL that only fails at run time."""
+ for so in SORT_ORDERS:
+ out, combined = run_qs(server, password,
+ ", @sort_order = '%s'" % _esc(so))
+ errs = find_sql_errors(combined)
+ R.check("SortOrder", "@sort_order = '%s' executes cleanly" % so,
+ not errs, str(errs[:2]))
+ R.check("SortOrder", "@sort_order = '%s' reaches completion" % so,
+ completed(out), "footer missing")
+
+
+def mode_matrix(server, password, R):
+ """@expert_mode and @format_output each rewrite the column list."""
+ for expert in (0, 1):
+ for fmt in (0, 1):
+ extra = ", @expert_mode = %d, @format_output = %d" % (expert, fmt)
+ out, combined = run_qs(server, password, extra)
+ errs = find_sql_errors(combined)
+ label = "expert_mode=%d format_output=%d" % (expert, fmt)
+ R.check("Modes", "%s executes cleanly" % label, not errs, str(errs[:2]))
+ R.check("Modes", "%s reaches completion" % label,
+ completed(out), "footer missing")
+
+ # A sort order combined with expert mode changes both ORDER BY and columns.
+ for so in ("cpu", "duration", "total waits", "plan count by hashes"):
+ out, combined = run_qs(server, password,
+ ", @sort_order = '%s', @expert_mode = 1" % _esc(so))
+ errs = find_sql_errors(combined)
+ R.check("Modes", "@sort_order = '%s' + expert_mode executes cleanly" % so,
+ not errs, str(errs[:2]))
+
+
+def filter_matrix(server, password, R):
+ """@wait_filter, @execution_type_desc and @query_type each add joins and
+ predicates that reshape the statement."""
+ for wf in WAIT_FILTERS:
+ out, combined = run_qs(server, password,
+ ", @wait_filter = '%s'" % _esc(wf))
+ errs = find_sql_errors(combined)
+ R.check("WaitFilter", "@wait_filter = '%s' executes cleanly" % wf,
+ not errs, str(errs[:2]))
+ R.check("WaitFilter", "@wait_filter = '%s' reaches completion" % wf,
+ completed(out), "footer missing")
+
+ for et in EXECUTION_TYPES:
+ out, combined = run_qs(server, password,
+ ", @execution_type_desc = '%s'" % _esc(et))
+ errs = find_sql_errors(combined)
+ R.check("ExecType", "@execution_type_desc = '%s' executes cleanly" % et,
+ not errs, str(errs[:2]))
+
+ for qt in QUERY_TYPES:
+ out, combined = run_qs(server, password,
+ ", @query_type = '%s'" % _esc(qt))
+ errs = find_sql_errors(combined)
+ R.check("QueryType", "@query_type = '%s' executes cleanly" % qt,
+ not errs, str(errs[:2]))
+
+
+def bidirectional_tests(server, password, R):
+ """Prove the filters actually filter, rather than being silently ignored.
+ Each presence assertion is paired with an absence assertion on the same
+ filter, and every absence run is checked for completion so an errored or
+ empty run cannot pass vacuously."""
+ # ---- @query_text_search --------------------------------------------
+ out, combined = run_qs(server, password,
+ ", @query_text_search = 'qs_test_proc'")
+ R.check("Search", "@query_text_search on a known string: no severe error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("Search", "@query_text_search on a known string: reaches completion",
+ completed(out), "footer missing")
+
+ out2, combined2 = run_qs(server, password,
+ ", @query_text_search = 'zzz_no_such_text_zzz'")
+ R.check("Search", "@query_text_search on nonsense: reaches completion "
+ "(positive control for the absence below)",
+ completed(out2), "footer missing")
+ R.check("Search", "@query_text_search on nonsense returns no query rows",
+ result_rows(out2) == 0,
+ "expected zero rows, got %d" % result_rows(out2))
+
+ # ---- @query_type separates the proc from the ad hoc queries --------
+ out, combined = run_qs(server, password, ", @query_type = 'proc'")
+ proc_rows = result_rows(out)
+ R.check("Search", "@query_type = 'proc': reaches completion",
+ completed(out), "footer missing")
+
+ out, combined = run_qs(server, password, ", @query_type = 'ad hoc'")
+ adhoc_rows = result_rows(out)
+ R.check("Search", "@query_type = 'ad hoc': reaches completion",
+ completed(out), "footer missing")
+ R.check("Search", "@query_type actually partitions proc vs ad hoc",
+ proc_rows > 0 and adhoc_rows > 0 and proc_rows != adhoc_rows,
+ "proc=%d adhoc=%d (expected both non-zero and different)"
+ % (proc_rows, adhoc_rows))
+
+ # ---- @top bounds the result set ------------------------------------
+ out, combined = run_qs(server, password, ", @top = 1")
+ R.check("Top", "@top = 1: no severe error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)))
+ R.check("Top", "@top = 1 returns at most one query row",
+ result_rows(out) <= 1, "got %d rows" % result_rows(out))
+
+ # ---- @execution_count filters out everything when set impossibly high
+ out, combined = run_qs(server, password, ", @execution_count = 1000000")
+ R.check("ExecCount", "@execution_count impossibly high: reaches completion",
+ completed(out), "footer missing")
+ R.check("ExecCount", "@execution_count impossibly high returns no rows",
+ result_rows(out) == 0, "got %d rows" % result_rows(out))
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--server", default="SQL2022")
+ ap.add_argument("--password", default="L!nt0044")
+ args = ap.parse_args()
+
+ print("Running sp_QuickieStore assertion tests against %s..." % args.server)
+ print()
+
+ out, _ = _sqlcmd(args.server, args.password,
+ "SET NOCOUNT ON; SELECT CASE WHEN OBJECT_ID(N'dbo.sp_QuickieStore', N'P') "
+ "IS NULL THEN 'MISSING' ELSE 'PRESENT' END;")
+ if "PRESENT" not in out:
+ print("ERROR: dbo.sp_QuickieStore is not installed in master on %s."
+ % args.server)
+ print("Install sp_QuickieStore.sql before running this harness.")
+ sys.exit(1)
+
+ R = Results()
+ try:
+ if build_fixture(args.server, args.password, R):
+ smoke_tests(args.server, args.password, R)
+ sort_order_matrix(args.server, args.password, R)
+ mode_matrix(args.server, args.password, R)
+ filter_matrix(args.server, args.password, R)
+ bidirectional_tests(args.server, args.password, R)
+ finally:
+ out, err = _sqlcmd(args.server, args.password, CLEANUP_SQL)
+ R.check("Fixture", "scratch database dropped",
+ not find_sql_errors(out + "\n" + err), "cleanup failed")
+
+ for item in R.items:
+ status = "PASS" if item["passed"] else "FAIL"
+ detail = (" (%s)" % item["detail"]) if item["detail"] and not item["passed"] else ""
+ print(" [%s] %s: %s%s" % (status, item["group"], item["name"], detail))
+
+ print()
+ print("Results: %d passed, %d failed, %d total"
+ % (R.passed, R.failed, R.passed + R.failed))
+
+ if R.failed:
+ print()
+ print("FAILED TESTS:")
+ for item in R.items:
+ if not item["passed"]:
+ print(" %s: %s (%s)" % (item["group"], item["name"], item["detail"]))
+ sys.exit(1)
+
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
From 0a4f92a0b4db0a643761bee4e0149e5f79f3be69 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 19 Jul 2026 11:28:02 -0400
Subject: [PATCH 46/60] sp_LogHunter: fix silent failure on @custom_message
over 128 chars; add harness
sp_LogHunter builds an xp_readerrorlog command string per search term and
executes each through sp_executesql, swallowing any that throw into #errors
rather than raising them. A failing search therefore returns a clean-looking
empty result set rather than an error.
Search arguments were wrapped in double quotes, which under QUOTED_IDENTIFIER
ON parse as IDENTIFIERS -- capped at 128 characters. A @custom_message longer
than that failed with Msg 103 ("The identifier that starts with ... is too
long"), went into #errors, and handed the caller an empty result set that reads
exactly like a clean bill of health. A 150-character search is not exotic;
pasting an error message fragment gets you there.
Emit N'...' instead. Both halves matter, and the second is why double quotes
were used originally: xp_readerrorlog is an extended stored procedure and does
not implicitly convert, so a bare single-quoted (varchar) argument fails with
Msg 22004 "Invalid Parameter Type" at severity 12 -- quiet enough to look like
finding nothing. Quoted identifiers happened to satisfy the Unicode
requirement; N'...' satisfies it deliberately, with no length limit.
Also widens #search.current_date from nvarchar(10) to nvarchar(12), since
N'20260720' is 11 characters and would have truncated to an unterminated
literal.
Adds sp_LogHunter/tests/run_tests.py (78 assertions) and wires it into CI:
- #errors must stay empty across the parameter matrix, which regression-tests
the four generated-command bugs already documented in the source
- a real positive control: RAISERROR(marker, 10, 1) WITH LOG writes a known
string to the error log, so "found when searched for" / "not found when never
written" / "not found by a default run" are all bidirectional
- date ranges and @custom_message_only proven the same way
The harness checks for Level 12 and above rather than the Level 16 its siblings
use, because Msg 22004 is severity 12 and a Level-16 filter walks past it.
Verified 78/78 on 2016, 2017, 2019, 2022 and 2025. Mutation-checked against the
pre-fix procedure: exactly the two long-message assertions fail there and the
other 76 still pass.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/sql-tests.yml | 14 +
sp_LogHunter/sp_LogHunter.sql | 85 +++--
sp_LogHunter/tests/README.md | 88 +++++
sp_LogHunter/tests/run_tests.py | 617 ++++++++++++++++++++++++++++++++
4 files changed, 777 insertions(+), 27 deletions(-)
create mode 100644 sp_LogHunter/tests/README.md
create mode 100644 sp_LogHunter/tests/run_tests.py
diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml
index 09f074d0..133bcfdd 100644
--- a/.github/workflows/sql-tests.yml
+++ b/.github/workflows/sql-tests.yml
@@ -201,6 +201,20 @@ jobs:
run: |
python3 sp_QueryReproBuilder/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
+ # sp_LogHunter builds an xp_readerrorlog command string per search term
+ # and executes each one, swallowing any that throw into #errors rather
+ # than raising them -- so a broken generated command looks like a clean,
+ # empty result set. The harness asserts #errors stays empty across the
+ # matrix, and writes a marker to the error log via RAISERROR ... WITH LOG
+ # as a positive control so the absence assertions cannot pass vacuously.
+ - name: sp_LogHunter assertion harness
+ env:
+ SA_PASSWORD: CI_Test#2026!
+ SQLCMD_BIN: /usr/local/bin/sqlcmd
+ SQLCMD_CONN_ARGS: "-C -N disable"
+ run: |
+ python3 sp_LogHunter/tests/run_tests.py --server localhost --password "$SA_PASSWORD"
+
# sp_QuickieStore is ~16k lines of dynamic SQL whose shape changes with
# almost every parameter, so this is a parameter matrix: all 39 sort
# orders, the wait filters, execution types, query types and the
diff --git a/sp_LogHunter/sp_LogHunter.sql b/sp_LogHunter/sp_LogHunter.sql
index 680722db..b2eb284c 100644
--- a/sp_LogHunter/sp_LogHunter.sql
+++ b/sp_LogHunter/sp_LogHunter.sql
@@ -378,14 +378,42 @@ BEGIN
id integer
IDENTITY
PRIMARY KEY CLUSTERED,
- search_string nvarchar(4000) DEFAULT N'""',
+ /*
+ Every argument below is emitted as an N-prefixed single-quoted
+ literal: N'like this'. Both halves of that matter.
+
+ NOT double quotes: under QUOTED_IDENTIFIER ON a double-quoted argument
+ parses as an IDENTIFIER, and SQL Server caps identifiers at 128
+ characters - so a @custom_message longer than that failed with
+ "Msg 103 ... The identifier that starts with ... is too long", was
+ swallowed by the CATCH below into #errors, and handed the caller an
+ empty result set that reads exactly like a clean bill of health.
+
+ NOT a bare single-quoted literal either: xp_readerrorlog is an
+ extended stored procedure and will not implicitly convert, so a
+ varchar argument fails with "Msg 22004 ... Invalid Parameter Type" -
+ at severity 12, quiet enough to look like simply finding nothing.
+ Double quotes happened to satisfy this because quoted identifiers are
+ inherently Unicode; N'...' satisfies it deliberately, and without the
+ length limit.
+
+ The wrapping reads awkwardly because a literal quote must be doubled:
+ N'N''' opens the argument (emitting N') and N'''' closes it
+ (emitting ').
+ */
+ search_string nvarchar(4000) DEFAULT N'N''''',
days_back nvarchar(30) NULL,
start_date nvarchar(30) NULL,
end_date nvarchar(30) NULL,
- [current_date] nvarchar(10)
- DEFAULT N'"' + CONVERT(nvarchar(10), DATEADD(DAY, 1, SYSDATETIME()), 112) + N'"',
+ /*
+ Widened from 10: the value is now N'20260720', which is 11 characters
+ rather than 10. At the old width it truncated to N'20260720 - an
+ unterminated literal that broke every generated command.
+ */
+ [current_date] nvarchar(12)
+ DEFAULT N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, 1, SYSDATETIME()), 112) + N'''',
search_order nvarchar(10)
- DEFAULT N'"DESC"',
+ DEFAULT N'N''DESC''',
command AS
CONVERT
(
@@ -539,10 +567,10 @@ BEGIN
FROM
(
VALUES
- (N'"Microsoft SQL Server"'),
- (N'"detected"'),
- (N'"SQL Server has encountered"'),
- (N'"Warning: Enterprise Server/CAL license used for this instance"')
+ (N'N''Microsoft SQL Server'''),
+ (N'N''detected'''),
+ (N'N''SQL Server has encountered'''),
+ (N'N''Warning: Enterprise Server/CAL license used for this instance''')
) AS x (search_string)
CROSS JOIN
(
@@ -558,17 +586,17 @@ BEGIN
date-range mode so the canary has a concrete floor.
*/
days_back =
- N'"' +
+ N'N''' +
CASE
WHEN @days_back IS NOT NULL
THEN CONVERT(nvarchar(10), DATEADD(DAY, CASE WHEN @days_back > -90 THEN -90 ELSE @days_back END, SYSDATETIME()), 112)
ELSE CONVERT(nvarchar(10), @start_date, 112)
END +
- N'"',
+ N'''',
start_date =
- N'"' + CONVERT(nvarchar(30), @start_date, 121) + N'"',
+ N'N''' + CONVERT(nvarchar(30), @start_date, 121) + N'''',
end_date =
- N'"' + CONVERT(nvarchar(30), @end_date, 121) + N'"'
+ N'N''' + CONVERT(nvarchar(30), @end_date, 121) + N''''
) AS c
WHERE @custom_message_only = 0
OPTION(RECOMPILE);
@@ -584,9 +612,9 @@ BEGIN
)
SELECT
search_string =
- N'"' +
- v.search_string +
- N'"',
+ N'N''' +
+ REPLACE(v.search_string, N'''', N'''''') +
+ N'''',
c.days_back,
c.start_date,
c.end_date
@@ -615,11 +643,11 @@ BEGIN
(
SELECT
days_back =
- N'"' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'"',
+ N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'''',
start_date =
- N'"' + CONVERT(nvarchar(30), @start_date, 121) + N'"',
+ N'N''' + CONVERT(nvarchar(30), @start_date, 121) + N'''',
end_date =
- N'"' + CONVERT(nvarchar(30), @end_date, 121) + N'"'
+ N'N''' + CONVERT(nvarchar(30), @end_date, 121) + N''''
) AS c
WHERE @custom_message_only = 0
OPTION(RECOMPILE);
@@ -642,16 +670,19 @@ BEGIN
(
VALUES
(
- /* xp_readerrorlog search strings are wrapped in double quotes
- (see the #search.command computed column), so any literal "
+ /* xp_readerrorlog search strings are wrapped in single quotes
+ (see the #search.command computed column), so any literal '
inside the user-supplied @custom_message must be doubled to
- avoid closing the argument early and producing an
- "Incorrect syntax near '+'" error when sp_executesql parses
- the generated batch. */
- N'"' + REPLACE(@custom_message, N'"', N'""') + N'"',
- N'"' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'"',
- N'"' + CONVERT(nvarchar(30), @start_date, 121) + N'"',
- N'"' + CONVERT(nvarchar(30), @end_date, 121) + N'"'
+ avoid closing the argument early and producing a syntax
+ error when sys.sp_executesql parses the generated batch. A
+ literal " needs no handling at all now that it is not a
+ delimiter, and the search string is no longer subject to the
+ 128-character identifier limit that silently dropped long
+ messages into #errors. */
+ N'N''' + REPLACE(@custom_message, N'''', N'''''') + N'''',
+ N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'''',
+ N'N''' + CONVERT(nvarchar(30), @start_date, 121) + N'''',
+ N'N''' + CONVERT(nvarchar(30), @end_date, 121) + N''''
)
) AS x (search_string, days_back, start_date, end_date)
WHERE @custom_message LIKE N'_%'
diff --git a/sp_LogHunter/tests/README.md b/sp_LogHunter/tests/README.md
new file mode 100644
index 00000000..92dba0e4
--- /dev/null
+++ b/sp_LogHunter/tests/README.md
@@ -0,0 +1,88 @@
+# sp_LogHunter Tests
+
+**Run before and after any change to `sp_LogHunter.sql`.** Compiling proves
+almost nothing here. sp_LogHunter is a *generator*: it never queries the error
+log directly, it builds an `EXECUTE master.dbo.xp_readerrorlog ...` command
+string in the PERSISTED computed column `#search.command` -- one per search
+string -- and runs each through `sys.sp_executesql` in a nested loop over every
+log archive. A command that concatenates cleanly and reads correctly can still
+fail the moment it executes.
+
+Worse, that failure is **silent by design**. The EXECUTE is wrapped in
+`BEGIN TRY`/`BEGIN CATCH`, and any command that throws is swallowed into
+`#errors` rather than raised. A broken search string therefore produces a
+clean-looking but quietly incomplete result set: the procedure reports a
+healthy server because it never managed to read the log.
+
+## Automated
+
+| Script | What it does |
+| --- | --- |
+| `run_tests.py` | Runs the parameter matrix, asserting `#errors` stays empty, plus bidirectional marker and date-range checks. 78 assertions. |
+
+```
+cd sp_LogHunter/tests
+python run_tests.py --server SQL2022
+```
+
+Takes `--server` and `--password` (default `SQL2022` / the standard local sa
+password). Expect `78`. A full run takes a few seconds.
+
+## What it actually covers
+
+**`#errors` must be empty.** This is the core assertion and it runs on every
+case in the matrix. The source documents four bugs already fixed this way -- a
+stray literal space silently ANDed into every search, a NULL date argument in
+date-range mode, an unescaped quote closing the argument early, and an absurd
+`@days_back` overflowing `DATEADD`. Every one of them landed in `#errors`
+instead of on the user's screen, so asserting that table is empty
+regression-tests all four at once and anything else of the same shape.
+
+**A real positive control.** `RAISERROR('', 10, 1) WITH LOG` writes a
+known string to the error log. Severity 10 is deliberate: it reaches the log
+without emitting a client-side `Msg ..., Level 12+` that the error detector
+would flag. The marker is chosen so none of the ~88 canned search strings match
+it and none of the noise filters delete it, which makes the pair meaningful:
+
+- `@custom_message` = the marker -> the row **is** returned
+- `@custom_message` = a string never written -> **not** returned, run still clean
+- a default run with no `@custom_message` -> **not** returned
+
+The same shape covers date ranges (a window containing the write returns it, a
+window 30 days earlier does not) and `@custom_message_only` (0 runs the canned
+sweep *and* the custom search; 1 returns strictly fewer rows). Every absence
+assertion is paired with a presence assertion on the same machinery, so nothing
+passes vacuously by returning an empty set.
+
+## Bug found and fixed by this harness
+
+A `@custom_message` longer than **128 characters** silently returned nothing.
+
+Search arguments were wrapped in double quotes, and under `QUOTED_IDENTIFIER ON`
+a double-quoted argument parses as an **identifier** -- which SQL Server caps at
+128 characters. Anything longer failed with `Msg 103 ... The identifier that
+starts with ... is too long`, was swallowed into `#errors`, and handed the
+caller an empty result set that reads exactly like a clean bill of health. A
+150-character search string is not exotic; pasting in an error message fragment
+gets you there easily.
+
+The fix emits `N'...'` instead. Both halves matter, and the second one is why
+the original code used double quotes in the first place: `xp_readerrorlog` is an
+extended stored procedure and does **not** implicitly convert, so a bare
+single-quoted (varchar) argument fails with `Msg 22004 ... Invalid Parameter
+Type` at severity 12 -- quiet enough to look like simply finding nothing. Quoted
+identifiers happened to satisfy the Unicode requirement; `N'...'` satisfies it
+deliberately, and has no length limit.
+
+Because that failure is severity **12**, this harness checks for `Level 12` and
+above rather than the `Level 16` its siblings use. Severity 11 is still
+excluded: sp_LogHunter's own parameter guards raise at 11 by design.
+
+## Side effect
+
+This harness writes a handful of informational lines to the SQL Server error
+log (the markers). That cannot be undone without cycling the log, which would be
+far more disruptive than the lines themselves, so it does not try. The marker
+text is fixed rather than random so repeated runs do not accumulate distinct
+junk. Nothing else on the instance is touched: no databases, no configuration,
+no sessions, no server settings.
diff --git a/sp_LogHunter/tests/run_tests.py b/sp_LogHunter/tests/run_tests.py
new file mode 100644
index 00000000..ff25d58c
--- /dev/null
+++ b/sp_LogHunter/tests/run_tests.py
@@ -0,0 +1,617 @@
+"""
+sp_LogHunter assertion test harness
+===================================
+sp_LogHunter is a GENERATOR. It does not query the error log directly: it
+builds an `EXECUTE master.dbo.xp_readerrorlog ...` command string in the
+PERSISTED computed column #search.command, once per search string, then runs
+each one through sys.sp_executesql inside a nested loop over every error log
+archive.
+
+That makes it a textbook case of this suite's characteristic bug: a command
+that concatenates cleanly, reads correctly, and still fails when executed.
+Worse, the failure is SILENT by design -- the EXECUTE is wrapped in
+BEGIN TRY/BEGIN CATCH, and a command that throws is swallowed into #errors
+rather than raised. A broken search string produces a clean-looking, quietly
+incomplete result set: the procedure reports a healthy server because it never
+managed to read the log.
+
+The source carries comments describing four such bugs already fixed:
+
+ * the second xp_readerrorlog search argument was a literal " ", which
+ xp_readerrorlog ANDs with the first -- silently requiring every matched
+ line to contain a space
+ * in date-range mode @days_back is retired to NULL, which made the canary
+ rows emit a NULL date argument and throw
+ * a literal " inside @custom_message closed the quoted argument early and
+ produced "Incorrect syntax near '+'"
+ * an absurd @days_back overflowed DATEADD and killed the run
+
+Every one of those lands in #errors instead of on the user's screen. So the
+core assertion here is simple and blunt: **#errors must be empty**, across
+every parameter combination that changes the generated command. That single
+assertion regression-tests all four fixes above and anything similar.
+
+On top of that, the harness proves the search actually searches, using a real
+positive control:
+
+ RAISERROR('', 10, 1) WITH LOG writes a known string to the error log.
+
+Severity 10 is deliberate: it reaches the log while emitting no client-side
+"Msg ..., Level 16" that the error detector would flag. The marker is chosen so
+that NONE of the ~88 canned search strings match it and none of the noise
+filters delete it, which makes the bidirectional pair meaningful:
+
+ * @custom_message = marker -> the row IS returned
+ * @custom_message = never-written -> it is NOT returned, run still clean
+ * default run, no @custom_message -> it is NOT returned
+
+and likewise for date ranges: a window containing the write returns it, a
+window 30 days in the past does not. Every absence assertion is paired with a
+presence assertion on the same machinery, so nothing can pass vacuously by
+returning an empty set.
+
+SIDE EFFECT: this harness writes two informational lines to the SQL Server
+error log (the markers). That cannot be undone without cycling the log, which
+would be far more disruptive than the two lines, so the harness does not try.
+The marker text is fixed rather than random so repeated runs do not accumulate
+distinct junk. Nothing else on the instance is touched: no databases, no
+configuration, no sessions.
+
+Usage:
+ python run_tests.py [--server SQL2022] [--password L!nt0044]
+
+Exits 1 if any assertion fails.
+"""
+
+import argparse
+import os
+import re
+import shlex
+import subprocess
+import sys
+
+
+# Written to the error log via RAISERROR ... WITH LOG, then searched for.
+# Chosen so that none of sp_LogHunter's ~88 canned search strings match it
+# (so a default run must NOT return it) and none of the "dumb messages"
+# DELETE filters remove it (so a targeted run MUST return it).
+MARKER = "DarlingDataLogHunterCanary"
+
+# Never written to the log by anything. The negative half of the pair.
+ABSENT_MARKER = "DarlingDataLogHunterNeverWrittenAnywhere"
+
+# A marker containing a literal double quote. Search strings are wrapped in
+# single quotes when the command is built, so a double quote must survive
+# untouched; it was previously the delimiter and had to be doubled.
+QUOTE_MARKER = 'DarlingDataLogHunter"Quoted"Canary'
+
+# A marker longer than 128 characters. Search strings used to be wrapped in
+# DOUBLE quotes, which under QUOTED_IDENTIFIER ON makes them identifiers --
+# capped at 128 characters. Anything longer failed with Msg 103, was swallowed
+# into #errors, and returned an empty result set that read as "nothing wrong".
+# 150 characters puts this comfortably past the old ceiling.
+LONG_MARKER = "DarlingDataLogHunterLongCanary" + ("X" * 120)
+
+# Columns expected in the #error_log result set (shape-regression guard).
+LOG_COLUMNS = ["table_name", "log_date", "process_info", "text"]
+
+# Diagnostic lines the procedure prints only under @debug = 1.
+DEBUG_MARKERS = ["@l_log:", "@t_searches:", "Declaring cursor"]
+
+
+def find_sql_errors(text):
+ """Return any SQL errors of severity 12 or higher found in text.
+
+ go-sqlcmd reports errors on stdout, so both streams have to be checked.
+
+ The bar is 12 here, not the 16 the other harnesses use, because the
+ failure this procedure actually suffers from is quieter than that:
+ xp_readerrorlog rejects a non-Unicode argument with "Msg 22004 ... Invalid
+ Parameter Type" at Level 12, which reads on screen as simply finding
+ nothing. A Level-16 filter walks straight past it -- the same blind spot
+ that once let a Level-11 Msg 4060 masquerade as a procedure bug.
+
+ Level 11 is deliberately still excluded: sp_LogHunter's own parameter
+ guards (non-sysadmin, invalid @language_id, @custom_message_only with no
+ message) raise at severity 11 by design, and those are expected output in
+ the validation tests rather than failures.
+ """
+ if not text:
+ return []
+ return re.findall(r"Msg \d+, Level 1[2-9][^\n]*", text)
+
+
+def _sqlcmd_prefix():
+ """The sqlcmd binary plus any connection args, overridable via environment so
+ one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the
+ go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN
+ at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the
+ self-signed cert and disable encryption, since the modern Go TLS stack
+ rejects the SQL Server 2017 container's certificate outright."""
+ return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split(
+ os.environ.get("SQLCMD_CONN_ARGS", ""))
+
+
+def _sqlcmd(server, password, sql, headers=True):
+ """Run a batch and return (stdout, stderr) decoded as UTF-8.
+
+ Capturing bytes and decoding as UTF-8 keeps any non-ASCII log text from
+ being mangled by the Windows console code page (text=True would do that).
+ Output is tab-delimited and trimmed (-W -s TAB) and the line width is maxed
+ (-w 65535) so wide log rows are not wrapped mid-row.
+ """
+ cmd = _sqlcmd_prefix() + [
+ "-S", server, "-U", "sa", "-P", password,
+ "-d", "master",
+ "-W", # trim trailing spaces
+ "-w", "65535", # do not wrap wide rows
+ "-s", "\t", # tab delimiter
+ ]
+ if not headers:
+ cmd += ["-h", "-1"]
+ cmd += ["-Q", sql]
+ r = subprocess.run(cmd, capture_output=True, timeout=300)
+ out = (r.stdout or b"").decode("utf-8", errors="replace")
+ err = (r.stderr or b"").decode("utf-8", errors="replace")
+ return out, err
+
+
+def _esc(s):
+ """Escape a T-SQL single-quoted literal."""
+ return s.replace("'", "''")
+
+
+def run_loghunter(server, password, args="", preamble=""):
+ """Run sp_LogHunter and return (stdout, stderr).
+
+ `preamble` allows a DECLARE block so date arguments can be computed
+ SERVER-side (SYSDATETIME), which avoids any clock or timezone skew between
+ this machine and the instance under test.
+ """
+ call = "EXECUTE dbo.sp_LogHunter %s;" % args if args else "EXECUTE dbo.sp_LogHunter;"
+ return _sqlcmd(server, password, "SET NOCOUNT ON; " + preamble + call)
+
+
+def write_marker(server, password, marker):
+ """Write a marker line into the SQL Server error log.
+
+ Severity 10 with WITH LOG reaches the error log without raising a
+ client-side Msg/Level 16 that find_sql_errors would flag as a failure.
+ Requires sysadmin, which the harness already needs to read the log at all.
+ """
+ out, err = _sqlcmd(server, password,
+ "SET NOCOUNT ON; RAISERROR('%s', 10, 1) WITH LOG;" % _esc(marker))
+ return out + "\n" + err
+
+
+def log_rows(stdout):
+ """Return the #error_log data rows as lists of tab-split fields.
+
+ A real row's first field is exactly '#error_log'. Error log text can
+ contain embedded newlines, which produce continuation lines carrying only
+ the tail of `text`; those do not start with the table marker and are
+ skipped. The '#errors' result set is likewise excluded (different marker).
+ """
+ rows = []
+ for line in stdout.splitlines():
+ fields = line.split("\t")
+ if fields and fields[0].strip() == "#error_log":
+ rows.append([f.strip() for f in fields])
+ return rows
+
+
+def failed_commands(stdout):
+ """Return the generated commands that THREW, from the #errors result set.
+
+ sp_LogHunter swallows a failing xp_readerrorlog command into #errors rather
+ than raising it, so this -- not the exit code, and not find_sql_errors -- is
+ how a broken generated command is detected. Anything here is a real defect.
+ """
+ cmds = []
+ for line in stdout.splitlines():
+ fields = line.split("\t")
+ if fields and fields[0].strip() == "#errors":
+ cmds.append(line.strip())
+ return cmds
+
+
+def log_header_ok(stdout):
+ """True if the #error_log result set header carries every expected column."""
+ for line in stdout.splitlines():
+ if "log_date" in line and "process_info" in line and "text" in line:
+ return all(col in line for col in LOG_COLUMNS)
+ return False
+
+
+def marker_found(stdout, marker):
+ """True if any #error_log row's text contains the marker."""
+ return any(marker in row[-1] for row in log_rows(stdout))
+
+
+class Results:
+ def __init__(self):
+ self.items = []
+
+ def check(self, group, name, condition, detail=""):
+ self.items.append({
+ "group": group,
+ "name": name,
+ "passed": bool(condition),
+ "detail": detail,
+ })
+
+ @property
+ def passed(self):
+ return sum(1 for r in self.items if r["passed"])
+
+ @property
+ def failed(self):
+ return sum(1 for r in self.items if not r["passed"])
+
+
+def clean_run(R, group, name, out, err, expect_rows=True):
+ """Assert one run was clean: no severe SQL error AND no generated command
+ landed in #errors. Returns the parsed rows so callers can assert further.
+
+ This is the workhorse. #errors being empty is the assertion that actually
+ matters, because that is where a malformed generated command goes to die
+ quietly.
+ """
+ combined = out + "\n" + err
+ errors = find_sql_errors(combined)
+ R.check(group, "%s: no severe SQL error" % name, not errors, str(errors[:3]))
+
+ failures = failed_commands(out)
+ R.check(group, "%s: no generated command failed (#errors empty)" % name,
+ not failures,
+ "%d command(s) threw, first: %s" % (len(failures), failures[:1]))
+
+ rows = log_rows(out)
+ if expect_rows:
+ R.check(group, "%s: returned the #error_log result set" % name,
+ log_header_ok(out), "#error_log header missing or incomplete")
+ return rows
+
+
+def smoke_tests(server, password, R):
+ """Structural assertions: the proc runs, emits a well-formed result set,
+ and honors @help / @debug."""
+ grp = "Smoke"
+
+ out, err = run_loghunter(server, password)
+ rows = clean_run(R, grp, "default run", out, err)
+ R.check(grp, "default run: returned at least one log row "
+ "(positive control -- the log is readable)",
+ len(rows) > 0, "no rows returned; is the error log empty?")
+
+ malformed = [r for r in rows if len(r) < 4]
+ R.check(grp, "default run: every row well-formed (4 fields)",
+ not malformed,
+ "%d rows, %d malformed: %s" % (len(rows), len(malformed), malformed[:2]))
+
+ # ---- @help = 1 -------------------------------------------------------
+ out, err = run_loghunter(server, password, "@help = 1")
+ combined = out + "\n" + err
+ R.check(grp, "@help = 1: no severe SQL error",
+ not find_sql_errors(combined), str(find_sql_errors(combined)[:3]))
+ R.check(grp, "@help = 1: returns help text",
+ "i'm sp_LogHunter" in out or "sp_LogHunter" in out, "help text not found")
+ R.check(grp, "@help = 1: emits no log rows (short-circuits)",
+ not log_rows(out), "log rows emitted under @help")
+
+ # ---- @debug = 1 ------------------------------------------------------
+ # Scoped to a single custom search so the debug dump of #search is one row
+ # rather than ~90, keeping the output small without changing the path.
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 1, @debug = 1" % MARKER)
+ combined = out + "\n" + err
+ clean_run(R, grp, "@debug = 1", out, err, expect_rows=False)
+ missing = [m for m in DEBUG_MARKERS if m not in combined]
+ R.check(grp, "@debug = 1: prints diagnostics",
+ not missing, "missing debug markers: %s" % missing)
+
+
+def canary_tests(server, password, R):
+ """Bidirectional proof that the search actually searches, using a real
+ marker written to the error log."""
+ grp = "Canary"
+
+ write_marker(server, password, MARKER)
+
+ # PRESENT: the marker is found when searched for. This is the positive
+ # control that makes every absence assertion below non-vacuous.
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 1" % MARKER)
+ clean_run(R, grp, "marker search", out, err, expect_rows=False)
+ R.check(grp, "marker PRESENT when searched for (proves search works)",
+ marker_found(out, MARKER),
+ "marker %r not returned; the custom search found nothing" % MARKER)
+
+ # ABSENT: a string never written is not found -- but the run is still
+ # clean, so this is a real negative rather than a crash.
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 1" % ABSENT_MARKER)
+ clean_run(R, grp, "never-written search", out, err, expect_rows=False)
+ R.check(grp, "never-written string ABSENT (search filters, not passes all)",
+ not marker_found(out, ABSENT_MARKER),
+ "a string that was never logged came back")
+
+ # ABSENT from a default run: none of the ~88 canned strings match the
+ # marker, so a default run must not surface it. This proves @custom_message
+ # is what found it above, not the canned sweep.
+ out, err = run_loghunter(server, password)
+ rows_default = clean_run(R, grp, "default run (no custom message)", out, err)
+ R.check(grp, "marker ABSENT from a default run (canned searches do not match it)",
+ not marker_found(out, MARKER),
+ "marker surfaced without @custom_message; it collides with a canned string")
+
+ # @custom_message_only = 0 runs BOTH the canned sweep and the custom
+ # search: the marker is found AND the canned results are still there.
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 0" % MARKER)
+ rows_both = clean_run(R, grp, "custom + canned", out, err)
+ R.check(grp, "@custom_message_only = 0: marker PRESENT (custom search ran)",
+ marker_found(out, MARKER), "marker missing when combined with canned sweep")
+ R.check(grp, "@custom_message_only = 0: canned sweep still ran "
+ "(more rows than the marker alone)",
+ len(rows_both) > 1,
+ "only %d row(s); the canned searches appear to have been skipped"
+ % len(rows_both))
+
+ # And the inverse: only = 1 must NOT run the canned sweep, so it returns
+ # strictly fewer rows than the combined run above.
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 1" % MARKER)
+ rows_only = log_rows(out)
+ R.check(grp, "@custom_message_only = 1: skips the canned sweep "
+ "(fewer rows than only = 0)",
+ len(rows_only) < len(rows_both),
+ "only=1 returned %d rows, only=0 returned %d -- the flag did nothing"
+ % (len(rows_only), len(rows_both)))
+
+
+def date_range_tests(server, password, R):
+ """Bidirectional proof that the date arguments actually bound the search.
+
+ Dates are computed server-side so no clock skew between this machine and
+ the instance can affect the result.
+ """
+ grp = "DateRange"
+
+ write_marker(server, password, MARKER)
+
+ # A window that contains the write: the marker comes back.
+ out, err = run_loghunter(
+ server, password,
+ "@start_date = @s, @end_date = @e, @custom_message = N'%s', "
+ "@custom_message_only = 1" % MARKER,
+ preamble="DECLARE @s datetime = DATEADD(HOUR, -1, SYSDATETIME()), "
+ "@e datetime = DATEADD(HOUR, 1, SYSDATETIME()); ")
+ clean_run(R, grp, "window containing the write", out, err, expect_rows=False)
+ R.check(grp, "marker PRESENT inside its window (proves date args work)",
+ marker_found(out, MARKER),
+ "marker not returned for a window that contains it")
+
+ # A window 30 days in the past: the same marker must NOT come back. The
+ # assertion above proves this run is capable of finding it, so this is a
+ # real filter test rather than an empty result set.
+ out, err = run_loghunter(
+ server, password,
+ "@start_date = @s, @end_date = @e, @custom_message = N'%s', "
+ "@custom_message_only = 1" % MARKER,
+ preamble="DECLARE @s datetime = DATEADD(DAY, -30, SYSDATETIME()), "
+ "@e datetime = DATEADD(DAY, -29, SYSDATETIME()); ")
+ clean_run(R, grp, "window before the write", out, err, expect_rows=False)
+ R.check(grp, "marker ABSENT outside its window (date range actually filters)",
+ not marker_found(out, MARKER),
+ "marker returned for a window 30 days before it was written")
+
+ # @first_log_only = 1 still finds a marker written moments ago, since the
+ # current log is log 0. Proves the flag narrows scope without breaking it.
+ out, err = run_loghunter(
+ server, password,
+ "@first_log_only = 1, @custom_message = N'%s', "
+ "@custom_message_only = 1" % MARKER)
+ clean_run(R, grp, "@first_log_only = 1", out, err, expect_rows=False)
+ R.check(grp, "@first_log_only = 1: still finds a marker in the current log",
+ marker_found(out, MARKER),
+ "marker missing when restricted to the first log")
+
+
+def generated_command_tests(server, password, R):
+ """The core matrix. Every case below changes how the xp_readerrorlog
+ command string is built; each asserts the generated command EXECUTED
+ cleanly (#errors empty). Several are direct regression tests for bugs
+ documented in the source."""
+ grp = "Generated"
+
+ cases = [
+ # (label, args, note)
+ ("@days_back = 0 (normalized to -1)", "@days_back = 0", ""),
+ ("@days_back positive (normalized to -7)", "@days_back = 7", ""),
+ # Regression: an absurd value once overflowed DATEADD and killed the run.
+ ("@days_back absurd (clamped to -36500)", "@days_back = -99999", ""),
+ ("@days_back = -1", "@days_back = -1", ""),
+ ("@first_log_only = 1", "@first_log_only = 1", ""),
+ ("@language_id = 1033 explicit", "@language_id = 1033", ""),
+ ]
+ for label, args, _note in cases:
+ out, err = run_loghunter(server, password, args)
+ clean_run(R, grp, label, out, err)
+
+ # Regression: in date-range mode @days_back is retired to NULL, and the
+ # canary rows then had to fall back to @start_date for their floor. When
+ # that fallback was missing the generated command received a NULL date and
+ # threw -- straight into #errors, invisible to the caller. All three
+ # date-range shapes are covered because the proc fills in whichever date
+ # the caller omitted.
+ date_cases = [
+ ("@start_date only",
+ "@start_date = @s",
+ "DECLARE @s datetime = DATEADD(DAY, -3, SYSDATETIME()); "),
+ ("@end_date only",
+ "@end_date = @e",
+ "DECLARE @e datetime = SYSDATETIME(); "),
+ ("@start_date and @end_date",
+ "@start_date = @s, @end_date = @e",
+ "DECLARE @s datetime = DATEADD(DAY, -3, SYSDATETIME()), "
+ "@e datetime = SYSDATETIME(); "),
+ ]
+ for label, args, preamble in date_cases:
+ out, err = run_loghunter(server, password, args, preamble=preamble)
+ clean_run(R, grp, "date-range mode: %s" % label, out, err)
+
+ # Regression: a literal " inside @custom_message closed the quoted
+ # xp_readerrorlog argument early and produced "Incorrect syntax near '+'".
+ # The command is built by concatenation, so this is a parse-time failure of
+ # the GENERATED batch -- exactly what #errors captures.
+ write_marker(server, password, QUOTE_MARKER)
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 1" % _esc(QUOTE_MARKER))
+ clean_run(R, grp, "@custom_message containing a double quote", out, err,
+ expect_rows=False)
+ R.check(grp, "@custom_message with a double quote still MATCHES "
+ "(quotes doubled, not dropped)",
+ marker_found(out, QUOTE_MARKER),
+ "quoted marker not returned; the quote handling changed its meaning")
+
+ # A single quote is escaped on the T-SQL side rather than the
+ # xp_readerrorlog side, so it exercises a different path.
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 1"
+ % _esc("DarlingData's LogHunter"))
+ clean_run(R, grp, "@custom_message containing a single quote", out, err,
+ expect_rows=False)
+
+ # Regression: a @custom_message longer than 128 characters. While search
+ # strings were wrapped in double quotes they parsed as identifiers, which
+ # SQL Server caps at 128 characters, so anything longer died with Msg 103
+ # ("The identifier that starts with ... is too long") -- into #errors,
+ # never onto the caller's screen. Asserting only that it does not throw
+ # would be too weak, so the marker is written to the log first and must
+ # come back: proof that a long search actually searches.
+ write_marker(server, password, LONG_MARKER)
+ out, err = run_loghunter(
+ server, password,
+ "@custom_message = N'%s', @custom_message_only = 1" % _esc(LONG_MARKER))
+ clean_run(R, grp, "@custom_message longer than 128 chars", out, err,
+ expect_rows=False)
+ R.check(grp, "@custom_message of %d chars MATCHES "
+ "(no 128-char identifier limit)" % len(LONG_MARKER),
+ marker_found(out, LONG_MARKER),
+ "long marker not returned; the search string hit a length limit")
+
+
+def validation_tests(server, password, R):
+ """The guard paths that RAISERROR and RETURN before doing any work."""
+ grp = "Validation"
+
+ # Invalid @language_id: rejected up front.
+ out, err = run_loghunter(server, password, "@language_id = 999999")
+ combined = out + "\n" + err
+ R.check(grp, "@language_id invalid: rejected with a message",
+ "not a valid language_id" in combined,
+ "expected language_id rejection, got: %r" % combined[:200])
+ R.check(grp, "@language_id invalid: returns no log rows",
+ not log_rows(out), "log rows emitted despite invalid language_id")
+
+ # @custom_message_only = 1 with nothing to search for: rejected, because
+ # it would otherwise leave #search empty and make the whole run a no-op
+ # that reports a clean bill of health.
+ for label, args in [
+ ("NULL @custom_message", "@custom_message_only = 1"),
+ ("empty @custom_message",
+ "@custom_message_only = 1, @custom_message = N''"),
+ ]:
+ out, err = run_loghunter(server, password, args)
+ combined = out + "\n" + err
+ R.check(grp, "@custom_message_only = 1 with %s: rejected" % label,
+ "requires a non-empty @custom_message" in combined,
+ "expected rejection, got: %r" % combined[:200])
+ R.check(grp, "@custom_message_only = 1 with %s: returns no log rows" % label,
+ not log_rows(out), "log rows emitted despite rejection")
+
+ # A valid non-English language_id warns rather than silently returning
+ # nothing, because the search strings are English literals. Only asserted
+ # when the instance actually has a non-English message set installed --
+ # most containers ship 1033 only, and asserting unconditionally would fail
+ # for a reason that has nothing to do with the procedure.
+ out, _ = _sqlcmd(
+ server, password,
+ "SET NOCOUNT ON; SELECT TOP (1) CONVERT(varchar(10), m.language_id) "
+ "FROM sys.messages AS m WHERE m.language_id <> 1033 "
+ "GROUP BY m.language_id;",
+ headers=False)
+ other = next((l.strip() for l in out.splitlines() if l.strip().isdigit()), None)
+ if other:
+ out, err = run_loghunter(server, password, "@language_id = %s" % other)
+ combined = out + "\n" + err
+ R.check(grp, "non-English @language_id warns instead of returning silence",
+ "will not translate" in combined,
+ "no warning for language_id %s" % other)
+
+
+def preflight(server, password):
+ out, _ = _sqlcmd(
+ server, password,
+ "SET NOCOUNT ON; SELECT CASE WHEN OBJECT_ID(N'dbo.sp_LogHunter', N'P') "
+ "IS NOT NULL THEN 'OK' ELSE 'MISSING' END;",
+ headers=False,
+ )
+ return "OK" in out
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--server", default="SQL2022")
+ ap.add_argument("--password", default="L!nt0044")
+ args = ap.parse_args()
+
+ try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ except Exception:
+ pass
+
+ print("Running sp_LogHunter assertion tests against %s..." % args.server)
+ print()
+
+ if not preflight(args.server, args.password):
+ print("ERROR: dbo.sp_LogHunter is not installed in master on %s." % args.server)
+ print("Install sp_LogHunter.sql before running this harness.")
+ sys.exit(1)
+
+ R = Results()
+ smoke_tests(args.server, args.password, R)
+ canary_tests(args.server, args.password, R)
+ date_range_tests(args.server, args.password, R)
+ generated_command_tests(args.server, args.password, R)
+ validation_tests(args.server, args.password, R)
+
+ for r in R.items:
+ status = "PASS" if r["passed"] else "FAIL"
+ detail = (" (%s)" % r["detail"]) if (not r["passed"] and r["detail"]) else ""
+ print(" [%s] %s: %s%s" % (status, r["group"], r["name"], detail))
+
+ print()
+ print("Results: %d passed, %d failed, %d total" % (R.passed, R.failed, len(R.items)))
+
+ if R.failed > 0:
+ print()
+ print("FAILED TESTS:")
+ for r in R.items:
+ if not r["passed"]:
+ print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"]))
+ sys.exit(1)
+ else:
+ print("All tests passed!")
+
+
+if __name__ == "__main__":
+ main()
From 03a84a8d7bc32539126c070389993aba630e0ba9 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 21 Jul 2026 16:21:06 -0400
Subject: [PATCH 47/60] sp_IndexCleanup: fix NULL database_name on Azure SQL DB
On Azure SQL DB, sys.databases.database_id is scoped to the logical
server while DB_ID(), DB_NAME(), and the database_id column in every
DMV are scoped to the database/elastic pool, and the two id spaces do
not have to agree (documented in the DB_ID() remarks). The procedure
stored the sys.databases id in #databases, so on Hyperscale
DB_NAME(@database_id) resolved to NULL and the first insert into a
NOT NULL database_name column died with Msg 515. The same mismatch
made fo.database_id = os.database_id match nothing, silently emptying
#operational_stats.
Fix, direction-proof on both counts:
- #databases now stores ISNULL(DB_ID(d.name), d.database_id) - the
local-space id, identical on box product - so every DMV predicate
(dm_db_index_operational_stats, dm_db_index_usage_stats, the
operational stats join) compares within one id space.
- Names are never derived from ids anymore: every
DB_NAME(@database_id) / DB_NAME(os.database_id) /
QUOTENAME(DB_NAME(@current_database_id)) site now uses
@current_database_name, parameterized into the dynamic SQL.
Verified: compiles on SQL 2017/2022/2025; full local harness on 2022
passes (adversarial 32/32, fixture-cases 31/31 including the execute
check, rule-coverage 40/40 covering @get_all_databases, no-access 4/4).
Co-Authored-By: Claude Fable 5
---
sp_IndexCleanup/sp_IndexCleanup.sql | 53 +++++++++++++++++++++++------
1 file changed, 43 insertions(+), 10 deletions(-)
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index f918a8dd..52dd3162 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -1360,6 +1360,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
RETURN;
END;
+ /*
+ Resolve the database_id locally with DB_ID() rather than trusting
+ sys.databases. On Azure SQL DB, sys.databases.database_id is scoped
+ to the logical server while DB_ID(), DB_NAME(), and the database_id
+ column in every DMV are scoped to the database/elastic pool, and the
+ two id spaces do not have to agree. Carrying the sys.databases id
+ made DB_NAME(@database_id) return NULL (Msg 515 on NOT NULL
+ database_name columns) and made DMV database_id joins match nothing.
+ DB_ID(d.name) returns the same value as d.database_id everywhere
+ except Azure, where it returns the id the DMVs actually use.
+ */
INSERT INTO
#databases
WITH
@@ -1370,7 +1381,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
SELECT
d.name,
- d.database_id
+ database_id =
+ ISNULL
+ (
+ DB_ID(d.name),
+ d.database_id
+ )
FROM sys.databases AS d
WHERE d.name = @database_name
AND d.state = 0
@@ -1404,7 +1420,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
SELECT
d.name,
- d.database_id
+ /* DB_ID(d.name) rather than d.database_id: see the single-database insert above */
+ database_id =
+ ISNULL
+ (
+ DB_ID(d.name),
+ d.database_id
+ )
FROM sys.databases AS d
WHERE d.database_id > 4 /* Skip system databases */
AND d.state = 0
@@ -1717,7 +1739,11 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SELECT DISTINCT
@database_id,
- database_name = DB_NAME(@database_id),
+ /*
+ @database_name, never DB_NAME(@database_id): on Azure SQL DB the id
+ spaces differ and DB_NAME() of a foreign-space id returns NULL
+ */
+ database_name = @database_name,
schema_id = s.schema_id,
schema_name = s.name,
object_id = i.object_id,
@@ -1889,6 +1915,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
EXECUTE sys.sp_executesql
@sql,
N'@database_id integer,
+ @database_name sysname,
@min_reads bigint,
@min_writes bigint,
@min_size_gb decimal(10,2),
@@ -1896,6 +1923,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@object_id integer,
@schema_name sysname',
@current_database_id,
+ @current_database_name,
@min_reads,
@min_writes,
@min_size_gb,
@@ -2298,7 +2326,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SELECT
os.database_id,
- database_name = DB_NAME(os.database_id),
+ database_name = @database_name,
schema_id = s.schema_id,
schema_name = s.name,
os.object_id,
@@ -2359,7 +2387,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
GROUP BY
os.database_id,
- DB_NAME(os.database_id),
s.schema_id,
s.name,
os.object_id,
@@ -2421,8 +2448,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
EXECUTE sys.sp_executesql
@sql,
N'@database_id integer,
+ @database_name sysname,
@object_id integer',
@current_database_id,
+ @current_database_name,
@object_id;
SET @rc = ROWCOUNT_BIG();
@@ -2549,7 +2578,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SELECT
database_id = @database_id,
- database_name = DB_NAME(@database_id),
+ database_name = @database_name,
i.object_id,
i.index_id,
s.schema_id,
@@ -2765,9 +2794,11 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
EXECUTE sys.sp_executesql
@sql,
N'@database_id integer,
+ @database_name sysname,
@object_id integer,
@min_rows bigint',
@current_database_id,
+ @current_database_name,
@object_id,
@min_rows;
@@ -2797,7 +2828,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SELECT
database_id = @database_id,
- database_name = DB_NAME(@database_id),
+ database_name = @database_name,
x.object_id,
x.index_id,
x.schema_id,
@@ -2965,8 +2996,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
EXECUTE sys.sp_executesql
@sql,
N'@database_id integer,
+ @database_name sysname,
@object_id integer',
@current_database_id,
+ @current_database_name,
@object_id;
SET @rc = ROWCOUNT_BIG();
@@ -3011,7 +3044,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
)
SELECT
@current_database_id,
- database_name = DB_NAME(@current_database_id),
+ database_name = @current_database_name,
id1.schema_id,
id1.schema_name,
id1.table_name,
@@ -3089,7 +3122,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHEN id1.is_unique_constraint = 1
THEN
N'ALTER TABLE ' +
- QUOTENAME(DB_NAME(@current_database_id)) +
+ QUOTENAME(@current_database_name) +
N'.' +
QUOTENAME(id1.schema_name) +
N'.' +
@@ -3115,7 +3148,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
N'INDEX ' +
QUOTENAME(id1.index_name) +
N' ON ' +
- QUOTENAME(DB_NAME(@current_database_id)) +
+ QUOTENAME(@current_database_name) +
N'.' +
QUOTENAME(id1.schema_name) +
N'.' +
From a08adad8f76cb18fa7af669dd6657f1e60e95c26 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:25:51 -0400
Subject: [PATCH 48/60] sp_HumanEventsBlockViewer: fix Msg 207 invalid column
error on real blocking data
The contentious-object inheritance UPDATE added in ae881f6 self-joins
#blocks on monitor_loop/blocking_desc/blocked_desc, but the SELECT INTO
#blocks projection never carried those columns, so the statement failed
to compile whenever the session had actually captured blocking. CI
missed it because with no blocking data the proc exits before that
statement compiles. Add the three chain identity keys to the #blocks
projection.
Co-Authored-By: Claude Fable 5
---
sp_HumanEvents/sp_HumanEventsBlockViewer.sql | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
index aa5ff891..e7714218 100644
--- a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
+++ b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
@@ -2478,7 +2478,11 @@ SELECT
kheb.currentdbname,
kheb.currentdbid,
kheb.blocked_process_report,
- kheb.sort_order
+ kheb.sort_order,
+ /* chain identity keys - the contentious-object inheritance UPDATE self-joins #blocks on these */
+ kheb.monitor_loop,
+ kheb.blocking_desc,
+ kheb.blocked_desc
INTO #blocks
FROM
(
From 8c1c659db04ca32f60b16c55a75a1b1399b91a85 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 21 Jul 2026 17:28:12 -0400
Subject: [PATCH 49/60] Export-SqlResults: add PowerShell exporter for proc
result sets
Runs a query or procedure and writes every result set to CSV, spilling
execution plans, deadlock graphs, blocked process reports, and long
query text or scripts to correctly-typed files (.sqlplan/.xdl/.xml/.sql).
Classifies values by XML root element, strips the processing-instruction
wrappers the toolkit procs use for clickable SSMS output, dedupes
identical artifacts by content hash, captures RAISERROR WITH NOWAIT
progress to messages.log, and honors GO batches. No module
dependencies - System.Data.SqlClient only - works in Windows PowerShell
5.1 and PowerShell 7. Tested against all ten procs on SQL Server 2025.
Co-Authored-By: Claude Fable 5
---
Export-SqlResults/Export-SqlResults.ps1 | 406 ++++++++++++++++++++++++
Export-SqlResults/README.md | 80 +++++
2 files changed, 486 insertions(+)
create mode 100644 Export-SqlResults/Export-SqlResults.ps1
create mode 100644 Export-SqlResults/README.md
diff --git a/Export-SqlResults/Export-SqlResults.ps1 b/Export-SqlResults/Export-SqlResults.ps1
new file mode 100644
index 00000000..9d041c55
--- /dev/null
+++ b/Export-SqlResults/Export-SqlResults.ps1
@@ -0,0 +1,406 @@
+<#
+.SYNOPSIS
+ Runs a query or procedure and exports every result set to CSV, spilling
+ artifact XML (execution plans, deadlock graphs, blocked process reports)
+ and oversized text (query text, repro scripts, DDL) to individual files
+ instead of trying to cram them into a cell.
+
+.DESCRIPTION
+ Built for the DarlingData toolkit - sp_QuickieStore, sp_QuickieCache,
+ sp_PressureDetector, sp_HealthParser, sp_HumanEvents, sp_HumanEventsBlockViewer,
+ sp_QueryReproBuilder, sp_IndexCleanup - plus sp_BlitzLock and anything else
+ that returns multiple result sets with XML scattered through them.
+
+ Uses System.Data.SqlClient directly - no PowerShell module required, so it
+ runs on a locked-down production server without installing anything. Works
+ in Windows PowerShell 5.1 and PowerShell 7.
+
+ Values are classified by their first XML node, not by substring sniffing:
+
+ root ShowPlanXML -> .sqlplan (always spills)
+ root deadlock / deadlock-list -> .xdl (always spills)
+ root blocked-process-report,
+ blocked-process, blocking-process -> .xml (always spills)
+ root event (raw XE wrapper) -> .xml (always spills)
+ other XML -> .xml (spills over -InlineMaxChars)
+ plain text -> .sql/.txt (spills over -InlineMaxChars)
+
+ The DarlingData procs wrap query text, repro scripts, and plans that fail
+ casting to xml in processing instructions (, ,
+ , ) to make them clickable in SSMS. Those wrappers
+ are stripped on export: wrapped plans land as openable .sqlplan files, and
+ query text / scripts land as runnable .sql files - inline cells get the
+ unwrapped text too.
+
+ Spilled files go under files\ and the CSV cell holds the relative path.
+ Spills are deduplicated by content hash - procs like sp_HealthParser repeat
+ the same deadlock graph on every process/frame row, so identical values
+ share one file. manifest.csv correlates result set, row, column, and file
+ (many cells can map to one file). Server messages
+ (RAISERROR WITH NOWAIT progress, PRINT output) stream to the console and to
+ messages.log. GO batch separators are honored in -Query and -InputFile.
+
+.EXAMPLE
+ .\Export-SqlResults.ps1 -ServerInstance . -Database master -Query "EXEC dbo.sp_QuickieStore @get_all_databases = 1;" -OutDir C:\temp\quickiestore
+
+.EXAMPLE
+ .\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -InputFile .\healthparser.sql -OutDir C:\temp\prod01-healthparser -CommandTimeout 600
+
+.EXAMPLE
+ .\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -Query "EXEC dbo.sp_HumanEventsBlockViewer @session_name = N'blocked_process_report';" -OutDir C:\temp\prod01-blocking -Credential (Get-Credential)
+
+.NOTES
+ Copyright 2026 Darling Data, LLC
+ https://www.erikdarling.com/
+
+ Released under MIT license
+
+ For support, head over to GitHub:
+ https://code.erikdarling.com
+#>
+[CmdletBinding(DefaultParameterSetName = 'Query')]
+param(
+ [Parameter(Mandatory)]
+ [string]$ServerInstance,
+
+ [string]$Database = 'master',
+
+ [Parameter(Mandatory, ParameterSetName = 'Query')]
+ [string]$Query,
+
+ [Parameter(Mandatory, ParameterSetName = 'File')]
+ [string]$InputFile,
+
+ [Parameter(Mandatory)]
+ [string]$OutDir,
+
+ # Values longer than this spill to their own file - XML and plain text alike.
+ # Keeps short values inline for triage; artifact XML (plans, deadlock graphs,
+ # blocked process reports) always spills regardless of size.
+ [int]$InlineMaxChars = 8000,
+
+ [int]$CommandTimeout = 600,
+
+ # SQL auth. Omit entirely for Windows auth. Passed as a SqlCredential so the
+ # password never lands in the connection string.
+ [pscredential]$Credential,
+
+ # Collapse newlines inside CSV cells to single spaces. Off by default:
+ # quoted CSV fields carry embedded newlines fine (Excel included), and
+ # collapsing destroys query text formatting.
+ [switch]$FlattenNewlines,
+
+ # Prefix cells starting with = + - @ with a single quote so Excel doesn't
+ # evaluate them as formulas (query text starting with -- is the usual
+ # victim). Off by default because it alters the raw data.
+ [switch]$ExcelSafe
+)
+
+$ErrorActionPreference = 'Stop'
+
+if ($PSCmdlet.ParameterSetName -eq 'File') {
+ if (-not (Test-Path $InputFile)) { throw "InputFile not found: $InputFile" }
+ $Query = Get-Content $InputFile -Raw
+}
+
+# Resolve OutDir against the PowerShell location, not the process CWD, so
+# relative paths work with the raw .NET file APIs used below.
+$OutDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutDir)
+New-Item -ItemType Directory -Path $OutDir -Force | Out-Null
+$filesDir = Join-Path $OutDir 'files'
+$messagesPath = Join-Path $OutDir 'messages.log'
+
+# Split on GO batch separators (line-start only, sqlcmd-style; GO repeats).
+function Split-SqlBatches {
+ param([string]$Sql)
+
+ $batches = New-Object System.Collections.Generic.List[string]
+ $sb = New-Object System.Text.StringBuilder
+
+ foreach ($line in ($Sql -split "`r?`n")) {
+ if ($line -match '^\s*GO(?:\s+(\d+))?\s*$') {
+ $repeat = 1
+ if ($Matches[1]) { $repeat = [int]$Matches[1] }
+ $text = $sb.ToString()
+ if ($text.Trim().Length -gt 0) {
+ for ($r = 0; $r -lt $repeat; $r++) { $batches.Add($text) }
+ }
+ [void]$sb.Clear()
+ }
+ else {
+ [void]$sb.AppendLine($line)
+ }
+ }
+
+ $text = $sb.ToString()
+ if ($text.Trim().Length -gt 0) { $batches.Add($text) }
+
+ return ,$batches
+}
+
+# Render a reader value as text without losing anything to culture or
+# PowerShell's default casts: datetime2 keeps its fractional seconds, and
+# varbinary handles/hashes come out as 0x hex instead of a byte list.
+function Format-SqlValue {
+ param($Value)
+
+ if ($Value -is [datetime]) {
+ return $Value.ToString('yyyy-MM-dd HH:mm:ss.fffffff', [System.Globalization.CultureInfo]::InvariantCulture)
+ }
+ if ($Value -is [System.DateTimeOffset]) {
+ return $Value.ToString('o', [System.Globalization.CultureInfo]::InvariantCulture)
+ }
+ if ($Value -is [byte[]]) {
+ if ($Value.Length -eq 0) { return '0x' }
+ return '0x' + [System.BitConverter]::ToString($Value).Replace('-', '')
+ }
+ if ($Value -is [timespan]) {
+ return $Value.ToString('c')
+ }
+ return [string]$Value
+}
+
+# Classify a value by its first XML node. Returns Text (unwrapped if it was a
+# processing instruction), Ext, and whether it always spills. Artifacts meant
+# to be opened in a tool always spill - a 4KB deadlock graph is useless inline,
+# because its value is being openable as a graph.
+function Resolve-Field {
+ param(
+ [string]$Value,
+ [bool]$LooksXml,
+ [string]$ColumnName
+ )
+
+ if ($LooksXml) {
+ $head = $Value.Substring(0, [Math]::Min(600, $Value.Length))
+ $m = [regex]::Match(
+ $head,
+ '(?s)^\s*(?:<\?xml\b.*?\?>\s*)?(?:\s*)*<(?\?)?(?[A-Za-z_][\w.\-]*)'
+ )
+ if ($m.Success) {
+ $nodeName = $m.Groups['name'].Value
+
+ if ($m.Groups['pi'].Success) {
+ if ($nodeName -eq 'xml') {
+ # A bare xml declaration with nothing recognizable after it.
+ return @{ Text = $Value; Ext = '.xml'; Always = $false }
+ }
+
+ # DarlingData processing-instruction wrapper: strip it and
+ # classify the payload. Plans that failed casting to xml become
+ # openable .sqlplan files; query text and scripts become .sql.
+ $inner = $Value -replace '(?s)^\s*<\?[A-Za-z_][\w.\-]*\s?', ''
+ $inner = $inner -replace '(?s)\s*\?>\s*$', ''
+
+ if ($inner.TrimStart().StartsWith('
+
+# Export-SqlResults
+
+A PowerShell script that runs a query or stored procedure and exports every result set to CSV, spilling execution plans, deadlock graphs, blocked process reports, and oversized query text or scripts to individual files that open in the right tool.
+
+SSMS grid results are a dead end for this stuff: plans get truncated, XML columns are unreadable, and nothing survives being pasted into a ticket. This script gets proc output onto disk in a shape you can actually send to someone.
+
+Built for the scripts in this repo — sp_QuickieStore, sp_QuickieCache, sp_PressureDetector, sp_HealthParser, sp_HumanEvents, sp_HumanEventsBlockViewer, sp_QueryReproBuilder, sp_IndexCleanup, sp_PerfCheck, sp_LogHunter — plus sp_BlitzLock and anything else that returns result sets with XML scattered through them.
+
+## Requirements
+
+- Windows PowerShell 5.1 or PowerShell 7 — no modules to install; it uses System.Data.SqlClient directly, so it runs on locked-down servers
+- Windows auth by default, or pass `-Credential` for SQL auth (sent as a SqlCredential, never embedded in the connection string)
+
+## Usage
+
+```powershell
+.\Export-SqlResults.ps1 -ServerInstance . -Database master -Query "EXEC dbo.sp_QuickieStore @get_all_databases = 1;" -OutDir C:\temp\quickiestore
+```
+
+```powershell
+.\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -InputFile .\healthparser.sql -OutDir C:\temp\prod01-healthparser -CommandTimeout 600
+```
+
+```powershell
+.\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -Query "EXEC dbo.sp_HumanEventsBlockViewer @session_name = N'blocked_process_report';" -OutDir C:\temp\prod01-blocking -Credential (Get-Credential)
+```
+
+## Parameters
+
+| Parameter Name | Data Type | Default Value | Description |
+|----------------|-----------|---------------|-------------|
+| @ServerInstance | string | required | Server or instance to connect to |
+| @Database | string | master | Database context for the query |
+| @Query | string | | T-SQL to run (this or -InputFile) |
+| @InputFile | string | | Path to a .sql file to run (GO batches are honored) |
+| @OutDir | string | required | Output folder; created if missing |
+| @InlineMaxChars | int | 8000 | Values longer than this spill to their own file; artifact XML (plans, deadlock graphs, blocked process reports) always spills |
+| @CommandTimeout | int | 600 | Command timeout in seconds |
+| @Credential | pscredential | | SQL auth; omit for Windows auth |
+| @FlattenNewlines | switch | off | Collapse newlines in CSV cells to spaces (quoted CSV carries newlines fine without it) |
+| @ExcelSafe | switch | off | Prefix cells starting with = + - @ so Excel does not evaluate them as formulas |
+
+## How values are classified
+
+Every value is classified by its first XML node, not by substring sniffing:
+
+| Content | Extension | Spills |
+|---------|-----------|--------|
+| root ShowPlanXML | .sqlplan | always |
+| root deadlock / deadlock-list | .xdl | always |
+| root blocked-process-report / blocked-process / blocking-process | .xml | always |
+| root event (raw Extended Events wrapper) | .xml | always |
+| other XML | .xml | over -InlineMaxChars |
+| plain text | .sql or .txt | over -InlineMaxChars |
+
+The scripts in this repo wrap query text, repro scripts, and plans that fail casting to xml in processing instructions (``, ``, ``, ``) to make them clickable in SSMS. Those wrappers are stripped on export: wrapped plans land as openable .sqlplan files, and query text and repro scripts land as runnable .sql files.
+
+## Output layout
+
+```
+OutDir\
+ resultset01.csv one CSV per result set, in output order
+ resultset02.csv
+ files\ spilled artifacts; CSV cells hold the relative path
+ rs02_r0001_c05_query_plan.sqlplan
+ rs02_r0001_c06_deadlock_graph.xdl
+ manifest.csv result set / row / column -> file correlation
+ messages.log RAISERROR WITH NOWAIT progress and PRINT output
+```
+
+Spilled files are deduplicated by content hash. Procs like sp_HealthParser repeat the same deadlock graph on every process and frame row — identical values share one file on disk, and the manifest maps every referencing cell to it.
+
+## Warning
+
+Exports are only as small as what you ask for. sp_HealthParser against a server with a deep system_health backlog can produce tens of thousands of distinct deadlock graphs and hundreds of megabytes of files. Aim it somewhere with disk space.
+
+Copyright 2026 Darling Data, LLC
+Released under MIT license
From 22cf0c521fc75ba503a6ddf2b0bba4d13899683b Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sat, 25 Jul 2026 11:10:35 -0400
Subject: [PATCH 50/60] GitHub Actions: add Claude Code PR review workflows
Adds automatic Claude code reviews on pull requests into dev, plus an
interactive workflow that responds to @claude mentions in issues and PR
comments (also the way to request reviews on fork PRs, which cannot see
repo secrets). Both authenticate with the CLAUDE_CODE_OAUTH_TOKEN secret.
Co-Authored-By: Claude Fable 5
---
.github/workflows/claude-code-review.yml | 82 ++++++++++++++++++++++++
.github/workflows/claude.yml | 39 +++++++++++
2 files changed, 121 insertions(+)
create mode 100644 .github/workflows/claude-code-review.yml
create mode 100644 .github/workflows/claude.yml
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 00000000..b38ec3c4
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,82 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ # Only auto-review PRs into dev. Promotions from dev to main re-package
+ # already-reviewed work, and comment @claude on any PR for an on-demand review.
+ branches: [dev]
+ types: [opened, synchronize, ready_for_review, reopened]
+ # To limit reviews to certain files, uncomment and adjust:
+ # paths:
+ # - "**/*.sql"
+ # - "**/*.ps1"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ claude-review:
+ # Skip draft PRs (reviewed when marked ready) and fork PRs: GitHub does not
+ # expose repo secrets to workflows triggered from forks, so the review can
+ # only run on same-repo branches. Use @claude comments for fork PRs.
+ if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ id-token: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+ track_progress: true
+ prompt: |
+ REPO: ${{ github.repository }}
+ PR NUMBER: ${{ github.event.pull_request.number }}
+
+ This repository contains Erik Darling's SQL Server troubleshooting stored
+ procedures. Most changes are to large, self-contained T-SQL stored
+ procedures, plus PowerShell tooling and CI scripts. The repository
+ CLAUDE.md is the canonical T-SQL style guide.
+
+ Review this pull request with these priorities:
+
+ 1. Correctness: logic errors, NULL handling, dynamic SQL construction
+ (quoting, QUOTENAME, parameterized sp_executesql), cursor handling,
+ error handling, and overflow safety (COUNT_BIG, ROWCOUNT_BIG).
+ 2. SQL Server version compatibility: procedures support a range of
+ versions (generally 2016 through 2025, plus Azure SQL DB and Managed
+ Instance where the procedure documents support). Flag newer T-SQL
+ syntax (STRING_AGG, CONCAT_WS, GREATEST/LEAST, TRIM, etc.) or DMV
+ columns used without the version/feature gating patterns the
+ procedure already uses elsewhere.
+ 3. Self-containment: procedures must not gain dependencies on helper
+ functions, views, or other database objects.
+ 4. Style: flag clear CLAUDE.md violations (tabs, -- comments instead of
+ /* */ blocks, abbreviated data types or keywords, missing table
+ aliases, missing semicolons). Do not nitpick.
+
+ Repo-specific rules:
+ - Install-All/DarlingData.sql is auto-generated by CI. If this PR edits
+ it directly, flag that as a blocking problem: only per-procedure
+ source files should change.
+ - The @version / @version_date lines in procedures only change in
+ release PRs from the maintainer (erikdarlingdata). If any other PR
+ bumps them, ask for that to be reverted.
+
+ The PR branch is already checked out in the current working directory.
+
+ Use inline comments for specific issues and one summary comment for the
+ overall review. Only the GitHub comments you post will be seen, so put
+ all feedback there rather than in a normal response. If the changes look
+ good, say so briefly and do not invent problems.
+
+ claude_args: |
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 00000000..2d7dc2af
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,39 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ # Only users with write access to the repo can trigger this; the action
+ # enforces that check on the commenting user. This is also the way to get a
+ # review on fork PRs, where the automatic review workflow cannot run.
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+ id-token: write
+ actions: read
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
From 9624ed87d502e72506d32197a5d12cc4d08e8f0f Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Tue, 28 Jul 2026 23:53:34 -0400
Subject: [PATCH 51/60] sp_QuickieStore: add @find_parameter_sensitive mode
Finds plan shapes (query_hash + query_plan_hash) whose runtime metrics
swing wildly across executions of the same plan. Ranks shapes by an
execution-weighted coefficient of variation built from the previously
unused stdev columns, feeds statistics from regular executions only
(aborted/exception counted separately), and annotates signals:
parameter sensitive, row driven, intermittent waits, memory and tempdb
swings, timeouts, PSP variants, and cross-shape divergence. Takeover
mode like @find_high_impact: summary result set, one row per shape,
plan XML, top waits on 2017+, variant flags on 2022+.
Also adds primary keys to both modes' staging temp tables and makes
object-name resolution deterministic in both.
Tested: 157-assertion matrix on SQL Server 2022 (23 new assertions,
including a skewed-workload fixture where the parameter sensitive
signal must fire); clean installs and version-gated runs on
2016/2017/2019/2022/2025; real-data validation against StackOverflow
databases on 2019, 2022, and 2025.
Co-Authored-By: Claude Fable 5
---
sp_QuickieStore/README.md | 11 +
sp_QuickieStore/sp_QuickieStore.sql | 2208 ++++++++++++++++++++++++++-
sp_QuickieStore/tests/README.md | 10 +-
sp_QuickieStore/tests/run_tests.py | 148 +-
4 files changed, 2338 insertions(+), 39 deletions(-)
diff --git a/sp_QuickieStore/README.md b/sp_QuickieStore/README.md
index f131e868..776cac6b 100644
--- a/sp_QuickieStore/README.md
+++ b/sp_QuickieStore/README.md
@@ -75,6 +75,7 @@ Use the `@expert_mode` parameter to return additional details.
| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash, may be skewed by query_hash and query_plan_hash bugs with forced plans/plan guides | 0 or 1 | 0 |
| @find_high_impact | bit | finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions | 0 or 1 | 0 |
| @primary_window | nvarchar | with @find_high_impact, restricts results to queries whose majority activity is in this window (business, off-hours, or weekend) | business, off-hours, or weekend (any unambiguous prefix works: b, biz, off, overnight, w, wknd, etc.) | NULL |
+| @find_parameter_sensitive | bit | finds plan shapes whose runtime metrics swing wildly across executions of the same plan: classic parameter sensitivity | 0 or 1 | 0 |
| @include_maintenance | bit | Set this bit to 1 to add maintenance operations such as index creation to the result set | 0 or 1 | 0 |
| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 |
| @log_database_name | sysname | database to store logging tables | a valid database name | NULL; current database |
@@ -120,6 +121,16 @@ EXECUTE dbo.sp_QuickieStore
@regression_baseline_end_date = '2025-01-08',
@regression_direction = 'regressed';
+-- Find the vital few queries consuming disproportionate resources
+EXECUTE dbo.sp_QuickieStore
+ @find_high_impact = 1;
+
+-- Find parameter sensitive plan shapes: same plan, wildly different runtimes.
+-- Results are one row per query_hash + query_plan_hash, ranked by how
+-- volatile the @sort_order metric is (coefficient of variation)
+EXECUTE dbo.sp_QuickieStore
+ @find_parameter_sensitive = 1;
+
-- Expert mode for additional details
EXECUTE dbo.sp_QuickieStore
@expert_mode = 1;
diff --git a/sp_QuickieStore/sp_QuickieStore.sql b/sp_QuickieStore/sp_QuickieStore.sql
index aaab9a5a..124c65c2 100644
--- a/sp_QuickieStore/sp_QuickieStore.sql
+++ b/sp_QuickieStore/sp_QuickieStore.sql
@@ -103,6 +103,7 @@ ALTER PROCEDURE
@include_maintenance bit = 0, /*Set this bit to 1 to add maintenance operations such as index creation to the result set*/
@find_high_impact bit = 0, /*finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions*/
@primary_window nvarchar(20) = NULL, /*with @find_high_impact, restricts results to queries whose majority activity is in this window: business, off-hours, or weekend*/
+ @find_parameter_sensitive bit = 0, /*finds plan shapes whose runtime metrics swing wildly across executions of the same plan: classic parameter sensitivity*/
@help bit = 0, /*return available parameter details, etc.*/
@debug bit = 0, /*prints dynamic sql, statement length, parameter and variable values, and raw temp table contents*/
@troubleshoot_performance bit = 0, /*set statistics xml on for queries against views*/
@@ -206,6 +207,7 @@ BEGIN
WHEN N'@include_maintenance' THEN N'Set this bit to 1 to add maintenance operations such as index creation to the result set'
WHEN N'@find_high_impact' THEN N'finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions'
WHEN N'@primary_window' THEN N'with @find_high_impact, restricts results to queries whose majority activity is in this window (business, off-hours, or weekend)'
+ WHEN N'@find_parameter_sensitive' THEN N'finds plan shapes whose runtime metrics swing wildly across executions of the same plan: classic parameter sensitivity'
WHEN N'@help' THEN 'how you got here'
WHEN N'@debug' THEN 'prints dynamic sql, statement length, parameter and variable values, and raw temp table contents'
WHEN N'@troubleshoot_performance' THEN 'set statistics xml on for queries against views'
@@ -269,6 +271,7 @@ BEGIN
WHEN N'@include_maintenance' THEN N'0 or 1'
WHEN N'@find_high_impact' THEN N'0 or 1'
WHEN N'@primary_window' THEN N'business, off-hours, or weekend (any unambiguous prefix works: b, biz, off, overnight, w, wknd, etc.)'
+ WHEN N'@find_parameter_sensitive' THEN N'0 or 1'
WHEN N'@help' THEN '0 or 1'
WHEN N'@debug' THEN '0 or 1'
WHEN N'@troubleshoot_performance' THEN '0 or 1'
@@ -332,6 +335,7 @@ BEGIN
WHEN N'@include_maintenance' THEN N'0'
WHEN N'@find_high_impact' THEN N'0'
WHEN N'@primary_window' THEN N'NULL'
+ WHEN N'@find_parameter_sensitive' THEN N'0'
WHEN N'@help' THEN '0'
WHEN N'@debug' THEN '0'
WHEN N'@troubleshoot_performance' THEN '0'
@@ -472,6 +476,66 @@ BEGIN
SELECT ' For flat workloads: consider forced parameterization, look for missing schema prefixes,' UNION ALL
SELECT ' temp table patterns causing recompilation, or RECOMPILE hints generating unique plans.';
+ /*
+ Parameter Sensitive column guide
+ */
+ SELECT
+ parameter_sensitive_columns =
+ 'when using @find_parameter_sensitive = 1, results are one row per plan shape (query_hash + query_plan_hash) with these columns:' UNION ALL
+ SELECT REPLICATE('-', 100) UNION ALL
+ SELECT 'a plan shape groups every plan_id that compiled to the same query_plan_hash for the same query_hash,' UNION ALL
+ SELECT ' so recompiles of the same plan are analyzed together. Wild metric swings within one shape mean' UNION ALL
+ SELECT ' the same plan is fast for some parameter values and slow for others: classic parameter sensitivity.' UNION ALL
+ SELECT ' Only regular executions feed the statistics; aborted and exception executions are counted separately.' UNION ALL
+ SELECT REPLICATE('-', 100) UNION ALL
+ SELECT 'database_name: the database being analyzed' UNION ALL
+ SELECT 'start_date, end_date: the time window analyzed' UNION ALL
+ SELECT 'object_name: the stored procedure, function, or trigger this query belongs to, or "Adhoc" for ad hoc SQL' UNION ALL
+ SELECT 'query_sql_text: representative query text (the most-executed query_id in this shape)' UNION ALL
+ SELECT 'query_plan: the most recently executed plan (XML) for this shape' UNION ALL
+ SELECT 'top_waits: top 3 Query Store wait categories for this shape (SQL 2017+ with wait stats enabled, omitted otherwise)' UNION ALL
+ SELECT 'query_hash, query_plan_hash: the grouping keys' UNION ALL
+ SELECT 'query_count, plan_count: distinct query_ids and plan_ids that make up this shape' UNION ALL
+ SELECT 'shapes_for_query_hash: how many distinct plan shapes this query_hash has overall.' UNION ALL
+ SELECT ' 1 shape with high volatility = one plan that cannot serve every parameter value.' UNION ALL
+ SELECT ' Multiple shapes = the optimizer also flips plans; check the other shapes'' averages in signals.' UNION ALL
+ SELECT 'query_id_list, plan_id_list: comma-separated IDs for drilling into Query Store views' UNION ALL
+ SELECT REPLICATE('-', 100) UNION ALL
+ SELECT 'ranked_on, volatility_score: results are ranked by the coefficient of variation (stdev / average) of the' UNION ALL
+ SELECT ' @sort_order metric (cpu, duration, physical reads, writes, memory, rows, tempdb; anything else = cpu).' UNION ALL
+ SELECT ' The stdev is properly combined across runtime stat intervals, weighted by executions, so one weird' UNION ALL
+ SELECT ' interval does not dominate. 0 = perfectly stable, 1 = the stdev equals the average, bigger = wilder.' UNION ALL
+ SELECT 'signals: rule-based reads on the swings. Nx means max is N times min for that metric' UNION ALL
+ SELECT ' (tiny mins are floored at 0.001 ms / 1 row first, so extreme ratios are lower bounds):' UNION ALL
+ SELECT ' parameter sensitive (cpu Nx, rows Nx) - big cpu swings while rows barely move. The plan does wildly' UNION ALL
+ SELECT ' different amounts of work for similar-sized results: the strongest sniffing signal.' UNION ALL
+ SELECT ' row driven (cpu Nx, rows Nx) - cpu swings track row count swings. Work scales with honest data volume,' UNION ALL
+ SELECT ' so this is probably not a parameter sensitivity problem.' UNION ALL
+ SELECT ' intermittent waits (duration Nx, cpu Nx) - duration swings without cpu swings: blocking, grants, or I/O,' UNION ALL
+ SELECT ' not parameter sensitivity. Check top_waits.' UNION ALL
+ SELECT ' memory grant swings (Nx) / tempdb swings (Nx) - grant or spill behavior varies between executions.' UNION ALL
+ SELECT ' timed out N times / errored N times - aborted (client timeout/cancel) and exception executions.' UNION ALL
+ SELECT ' Timeouts on a volatile shape are often the bad-parameter executions themselves.' UNION ALL
+ SELECT ' psp variants involved (2022+) - Parameter Sensitive Plan optimization already dispatched variants here.' UNION ALL
+ SELECT ' other plan shapes exist (N, avg cpu X to Y ms) - the same query_hash compiled to other shapes whose' UNION ALL
+ SELECT ' averages diverge: the optimizer is flipping between good and bad plans.' UNION ALL
+ SELECT REPLICATE('-', 100) UNION ALL
+ SELECT 'total_executions, aborted_executions, exception_executions: execution counts for this shape in the window' UNION ALL
+ SELECT 'min/avg/max cpu, duration (ms) and rows, each with a volatility (coefficient of variation) column' UNION ALL
+ SELECT 'last_execution_time: the most recent regular execution of this shape in the time window' UNION ALL
+ SELECT 'variance_metrics: clickable XML rollup with min/avg/max/volatility for memory, physical reads, writes,' UNION ALL
+ SELECT ' and tempdb (2017+), plus max DOP. Click the column in SSMS to see the full breakdown.' UNION ALL
+ SELECT REPLICATE('-', 100) UNION ALL
+ SELECT 'what this mode cannot tell you: Query Store never records which parameter values produced the min or max,' UNION ALL
+ SELECT ' so this convicts the query, not the values. Compare compiled parameter values across the plan_ids in' UNION ALL
+ SELECT ' plan_id_list, or use sp_HumanEvents to catch live executions with runtime parameter values.' UNION ALL
+ SELECT REPLICATE('-', 100) UNION ALL
+ SELECT 'SHAPE VOLATILITY SUMMARY (separate result set, returned before the query details):' UNION ALL
+ SELECT 'total_shapes: how many plan shapes had regular executions in the time window' UNION ALL
+ SELECT 'shapes_past_floors: how many cleared the noise floors (executions and minimum cpu/duration)' UNION ALL
+ SELECT 'surfaced_shapes: how many made it into the detail result set after top-N ranking' UNION ALL
+ SELECT 'multi_shape_query_hashes: how many query_hashes compiled to more than one plan shape in the window';
+
/*
Limitations
*/
@@ -785,6 +849,41 @@ BEGIN
RETURN;
END;
+/*
+@find_parameter_sensitive is a takeover mode like @find_high_impact,
+so the two of them can't be combined, and it can't be combined with
+the things @find_high_impact can't be combined with either
+*/
+IF
+(
+ @find_parameter_sensitive = 1
+AND @find_high_impact = 1
+)
+BEGIN
+ RAISERROR('@find_parameter_sensitive cannot be used with @find_high_impact. Pick one mode per run.', 11, 1) WITH NOWAIT;
+ RETURN;
+END;
+
+IF
+(
+ @find_parameter_sensitive = 1
+AND @get_all_databases = 1
+)
+BEGIN
+ RAISERROR('@find_parameter_sensitive cannot be used with @get_all_databases. Run @find_parameter_sensitive against each database individually.', 11, 1) WITH NOWAIT;
+ RETURN;
+END;
+
+IF
+(
+ @log_to_table = 1
+AND @find_parameter_sensitive = 1
+)
+BEGIN
+ RAISERROR('@log_to_table cannot be used with @find_parameter_sensitive. Run them separately.', 11, 1) WITH NOWAIT;
+ RETURN;
+END;
+
/*
@primary_window only applies to the @find_high_impact path, and must
match one of the three bucket labels by case-insensitive prefix: b/o/w
@@ -4801,7 +4900,8 @@ BEGIN
CREATE TABLE
#hi_representative_text
(
- query_hash binary(8) NOT NULL,
+ query_hash binary(8) NOT NULL
+ PRIMARY KEY CLUSTERED,
query_sql_text nvarchar(max) NULL,
rn bigint NOT NULL
);
@@ -4836,7 +4936,9 @@ BEGIN
query_hash binary(8) NOT NULL,
wait_category_desc nvarchar(60) NOT NULL,
total_wait_ms decimal(38, 6) NOT NULL,
- rn bigint NOT NULL
+ rn bigint NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, rn)
);
CREATE TABLE
@@ -4853,21 +4955,27 @@ BEGIN
#hi_id_staging_queries
(
query_hash binary(8) NOT NULL,
- query_id bigint NOT NULL
+ query_id bigint NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_id)
);
CREATE TABLE
#hi_id_staging_plans
(
query_hash binary(8) NOT NULL,
- plan_id bigint NOT NULL
+ plan_id bigint NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, plan_id)
);
CREATE TABLE
#hi_id_staging_objects
(
query_hash binary(8) NOT NULL,
- object_id integer NOT NULL
+ object_id integer NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, object_id)
);
CREATE TABLE
@@ -5970,6 +6078,8 @@ OPTION(RECOMPILE);' + @nc10;
so.object_id
FROM #hi_id_staging_objects AS so
WHERE so.query_hash = i.query_hash
+ ORDER BY
+ so.object_id
),
@database_id
)
@@ -5984,6 +6094,8 @@ OPTION(RECOMPILE);' + @nc10;
so.object_id
FROM #hi_id_staging_objects AS so
WHERE so.query_hash = i.query_hash
+ ORDER BY
+ so.object_id
),
@database_id
)
@@ -6823,35 +6935,1763 @@ OUTER APPLY
(
SELECT
1/0
- FROM #hi_id_staging_plans AS sp
- WHERE qsp.plan_id = sp.plan_id
- AND o.query_hash = sp.query_hash
+ FROM #hi_id_staging_plans AS sp
+ WHERE qsp.plan_id = sp.plan_id
+ AND o.query_hash = sp.query_hash
+ )
+ ) AS qp0
+ WHERE qp0.n = 1
+) AS qp
+' +
+ CASE
+ WHEN @primary_window IS NULL
+ THEN N''
+ WHEN LOWER(@primary_window) LIKE N'b%'
+ THEN N'WHERE o.primary_window LIKE N''Business%''' + @nc10
+ WHEN LOWER(@primary_window) LIKE N'o%'
+ THEN N'WHERE o.primary_window LIKE N''Off-hours%''' + @nc10
+ WHEN LOWER(@primary_window) LIKE N'w%'
+ THEN N'WHERE o.primary_window LIKE N''Weekend%''' + @nc10
+ ELSE N''
+ END + N'ORDER BY
+ o.impact_score DESC,
+ ' +
+ CASE LOWER(@sort_order)
+ WHEN 'duration' THEN N'o.duration_share'
+ WHEN 'physical reads' THEN N'o.physical_reads_share'
+ WHEN 'writes' THEN N'o.writes_share'
+ WHEN 'memory' THEN N'o.memory_share'
+ WHEN 'executions' THEN N'o.executions_share'
+ ELSE N'o.cpu_share'
+ END + N' DESC
+OPTION(RECOMPILE);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ EXECUTE sys.sp_executesql
+ @sql,
+ N'@database_name sysname,
+ @start_date datetimeoffset(7),
+ @end_date datetimeoffset(7),
+ @utc_offset_string varchar(6),
+ @timezone sysname',
+ @database_name,
+ @start_date,
+ @end_date,
+ @utc_offset_string,
+ @timezone;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ SET STATISTICS XML OFF;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_update,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_info,
+ N'@sql nvarchar(max),
+ @current_table nvarchar(100)',
+ @sql,
+ @current_table;
+ END;
+
+ /*Exit: go to DEBUG dump or return*/
+ IF @debug = 1
+ BEGIN
+ GOTO DEBUG;
+ END;
+ ELSE
+ BEGIN
+ RETURN;
+ END;
+END; /*End @find_high_impact*/
+
+/*
+Find parameter sensitive queries: plan shapes (query_hash + query_plan_hash)
+whose runtime metrics swing wildly across executions of the same plan.
+Groups every plan_id that compiled to the same shape, computes a proper
+execution-weighted coefficient of variation from the stdev columns, and
+ranks shapes by how unpredictable the @sort_order metric is.
+*/
+IF @find_parameter_sensitive = 1
+BEGIN
+ /*Create temp tables for parameter sensitivity analysis*/
+ CREATE TABLE
+ #ps_intervals
+ (
+ runtime_stats_interval_id bigint NOT NULL
+ PRIMARY KEY CLUSTERED
+ );
+
+ CREATE TABLE
+ #ps_shape_stats
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ query_count bigint NOT NULL,
+ plan_count bigint NOT NULL,
+ last_execution_time datetimeoffset(7) NULL,
+ total_executions bigint NOT NULL,
+ aborted_executions bigint NOT NULL,
+ exception_executions bigint NOT NULL,
+ total_cpu_ms decimal(38, 6) NULL,
+ min_cpu_ms decimal(38, 6) NULL,
+ avg_cpu_ms decimal(38, 6) NULL,
+ max_cpu_ms decimal(38, 6) NULL,
+ cpu_volatility decimal(19, 4) NULL,
+ total_duration_ms decimal(38, 6) NULL,
+ min_duration_ms decimal(38, 6) NULL,
+ avg_duration_ms decimal(38, 6) NULL,
+ max_duration_ms decimal(38, 6) NULL,
+ duration_volatility decimal(19, 4) NULL,
+ min_rows bigint NULL,
+ avg_rows decimal(38, 6) NULL,
+ max_rows bigint NULL,
+ rows_volatility decimal(19, 4) NULL,
+ min_memory_mb decimal(38, 6) NULL,
+ avg_memory_mb decimal(38, 6) NULL,
+ max_memory_mb decimal(38, 6) NULL,
+ memory_volatility decimal(19, 4) NULL,
+ min_physical_reads_mb decimal(38, 6) NULL,
+ avg_physical_reads_mb decimal(38, 6) NULL,
+ max_physical_reads_mb decimal(38, 6) NULL,
+ physical_reads_volatility decimal(19, 4) NULL,
+ min_writes_mb decimal(38, 6) NULL,
+ avg_writes_mb decimal(38, 6) NULL,
+ max_writes_mb decimal(38, 6) NULL,
+ writes_volatility decimal(19, 4) NULL,
+ min_tempdb_mb decimal(38, 6) NULL,
+ avg_tempdb_mb decimal(38, 6) NULL,
+ max_tempdb_mb decimal(38, 6) NULL,
+ tempdb_volatility decimal(19, 4) NULL,
+ max_dop bigint NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash)
+ );
+
+ CREATE TABLE
+ #ps_query_rollup
+ (
+ query_hash binary(8) NOT NULL
+ PRIMARY KEY CLUSTERED,
+ shape_count bigint NOT NULL,
+ min_shape_avg_cpu_ms decimal(38, 6) NULL,
+ max_shape_avg_cpu_ms decimal(38, 6) NULL
+ );
+
+ CREATE TABLE
+ #ps_interesting
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash)
+ );
+
+ CREATE TABLE
+ #ps_id_staging
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ query_id bigint NOT NULL,
+ plan_id bigint NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash, query_id, plan_id)
+ );
+
+ CREATE TABLE
+ #ps_representative_text
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ query_sql_text nvarchar(max) NULL,
+ rn bigint NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash)
+ );
+
+ CREATE TABLE
+ #ps_object_staging
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ object_id integer NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash, object_id)
+ );
+
+ CREATE TABLE
+ #ps_wait_staging
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ wait_category_desc nvarchar(60) NOT NULL,
+ total_wait_ms decimal(38, 6) NOT NULL,
+ rn bigint NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash, rn)
+ );
+
+ CREATE TABLE
+ #ps_shape_waits
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ top_waits nvarchar(max) NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash)
+ );
+
+ CREATE TABLE
+ #ps_variant_flags
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ has_variants bit NOT NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash)
+ );
+
+ CREATE TABLE
+ #ps_identifiers
+ (
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ query_id_list nvarchar(max) NULL,
+ plan_id_list nvarchar(max) NULL,
+ object_name nvarchar(500) NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash)
+ );
+
+ CREATE TABLE
+ #ps_output
+ (
+ object_name nvarchar(500) NULL,
+ query_sql_text nvarchar(max) NULL,
+ top_waits nvarchar(max) NULL,
+ query_hash binary(8) NOT NULL,
+ query_plan_hash binary(8) NOT NULL,
+ query_count bigint NOT NULL,
+ plan_count bigint NOT NULL,
+ shapes_for_query_hash bigint NOT NULL,
+ query_id_list nvarchar(max) NULL,
+ plan_id_list nvarchar(max) NULL,
+ ranked_on nvarchar(40) NOT NULL,
+ volatility_score decimal(19, 4) NULL,
+ signals nvarchar(4000) NULL,
+ total_executions bigint NOT NULL,
+ aborted_executions bigint NOT NULL,
+ exception_executions bigint NOT NULL,
+ min_cpu_ms decimal(38, 6) NULL,
+ avg_cpu_ms decimal(38, 6) NULL,
+ max_cpu_ms decimal(38, 6) NULL,
+ cpu_volatility decimal(19, 4) NULL,
+ min_duration_ms decimal(38, 6) NULL,
+ avg_duration_ms decimal(38, 6) NULL,
+ max_duration_ms decimal(38, 6) NULL,
+ duration_volatility decimal(19, 4) NULL,
+ min_rows bigint NULL,
+ avg_rows decimal(38, 6) NULL,
+ max_rows bigint NULL,
+ rows_volatility decimal(19, 4) NULL,
+ last_execution_time datetimeoffset(7) NULL,
+ variance_metrics xml NULL,
+ PRIMARY KEY CLUSTERED
+ (query_hash, query_plan_hash)
+ );
+
+ /*
+ Local knobs: minimum executions for variance to mean anything,
+ and which metric's volatility ranks the results
+ */
+ DECLARE
+ @ps_min_executions bigint =
+ ISNULL(@execution_count, 2),
+ @ps_rank_metric nvarchar(20) =
+ CASE
+ WHEN @sort_order IN
+ (
+ 'duration',
+ 'physical reads',
+ 'writes',
+ 'memory',
+ 'rows'
+ )
+ THEN @sort_order
+ WHEN @sort_order = 'tempdb'
+ AND @new = 1
+ THEN @sort_order
+ ELSE N'cpu'
+ END;
+
+ /*Step 1a: Stage interval IDs for the time window*/
+ SELECT
+ @current_table = 'inserting #ps_intervals',
+ @sql = @isolation_level;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ EXECUTE sys.sp_executesql
+ @troubleshoot_insert,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ SET STATISTICS XML ON;
+ END;
+
+ SELECT
+ @sql += N'
+SELECT
+ qsrsi.runtime_stats_interval_id
+FROM ' + @database_name_quoted + N'.sys.query_store_runtime_stats_interval AS qsrsi
+WHERE qsrsi.start_time >= @start_date
+AND qsrsi.start_time < @end_date
+OPTION(RECOMPILE);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ INSERT
+ #ps_intervals WITH (TABLOCK)
+ (
+ runtime_stats_interval_id
+ )
+ EXECUTE sys.sp_executesql
+ @sql,
+ N'@start_date datetimeoffset(7),
+ @end_date datetimeoffset(7)',
+ @start_date,
+ @end_date;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ SET STATISTICS XML OFF;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_update,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_info,
+ N'@sql nvarchar(max),
+ @current_table nvarchar(100)',
+ @sql,
+ @current_table;
+ END;
+
+ /*
+ Step 1b: Aggregate runtime stats to plan shape level.
+ Only regular executions (execution_type = 0) feed the statistics,
+ because aborted executions have truncated metrics that poison
+ min/max and stdev; they get counted separately instead.
+ The variance combination is the standard execution-weighted
+ formula: combined variance = SUM(n * (stdev^2 + avg^2)) / SUM(n)
+ minus the square of the combined average, computed in float
+ because the native microsecond values squared overflow decimals.
+ */
+ SELECT
+ @current_table = 'inserting #ps_shape_stats',
+ @sql = @isolation_level;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ EXECUTE sys.sp_executesql
+ @troubleshoot_insert,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ SET STATISTICS XML ON;
+ END;
+
+ SELECT
+ @sql += N'
+SELECT
+ x.query_hash,
+ x.query_plan_hash,
+ x.query_count,
+ x.plan_count,
+ x.last_execution_time,
+ total_executions = x.n,
+ x.aborted_executions,
+ x.exception_executions,
+ total_cpu_ms =
+ CONVERT(decimal(38, 6), x.cpu_sum / 1000.0),
+ min_cpu_ms =
+ CONVERT(decimal(38, 6), x.cpu_min / 1000.0),
+ avg_cpu_ms =
+ CONVERT(decimal(38, 6), x.cpu_sum / v.n_float / 1000.0),
+ max_cpu_ms =
+ CONVERT(decimal(38, 6), x.cpu_max / 1000.0),
+ cpu_volatility =
+ CONVERT
+ (
+ decimal(19, 4),
+ SQRT
+ (
+ CASE
+ WHEN (x.cpu_ex2 / v.n_float) - POWER(x.cpu_sum / v.n_float, 2) < 0
+ THEN 0
+ ELSE (x.cpu_ex2 / v.n_float) - POWER(x.cpu_sum / v.n_float, 2)
+ END
+ ) /
+ NULLIF(x.cpu_sum / v.n_float, 0)
+ ),
+ total_duration_ms =
+ CONVERT(decimal(38, 6), x.duration_sum / 1000.0),
+ min_duration_ms =
+ CONVERT(decimal(38, 6), x.duration_min / 1000.0),
+ avg_duration_ms =
+ CONVERT(decimal(38, 6), x.duration_sum / v.n_float / 1000.0),
+ max_duration_ms =
+ CONVERT(decimal(38, 6), x.duration_max / 1000.0),
+ duration_volatility =
+ CONVERT
+ (
+ decimal(19, 4),
+ SQRT
+ (
+ CASE
+ WHEN (x.duration_ex2 / v.n_float) - POWER(x.duration_sum / v.n_float, 2) < 0
+ THEN 0
+ ELSE (x.duration_ex2 / v.n_float) - POWER(x.duration_sum / v.n_float, 2)
+ END
+ ) /
+ NULLIF(x.duration_sum / v.n_float, 0)
+ ),
+ min_rows =
+ CONVERT(bigint, x.rows_min),
+ avg_rows =
+ CONVERT(decimal(38, 6), x.rows_sum / v.n_float),
+ max_rows =
+ CONVERT(bigint, x.rows_max),
+ rows_volatility =
+ CONVERT
+ (
+ decimal(19, 4),
+ SQRT
+ (
+ CASE
+ WHEN (x.rows_ex2 / v.n_float) - POWER(x.rows_sum / v.n_float, 2) < 0
+ THEN 0
+ ELSE (x.rows_ex2 / v.n_float) - POWER(x.rows_sum / v.n_float, 2)
+ END
+ ) /
+ NULLIF(x.rows_sum / v.n_float, 0)
+ ),
+ min_memory_mb =
+ CONVERT(decimal(38, 6), x.memory_min * 8.0 / 1024.0),
+ avg_memory_mb =
+ CONVERT(decimal(38, 6), x.memory_sum / v.n_float * 8.0 / 1024.0),
+ max_memory_mb =
+ CONVERT(decimal(38, 6), x.memory_max * 8.0 / 1024.0),
+ memory_volatility =
+ CONVERT
+ (
+ decimal(19, 4),
+ SQRT
+ (
+ CASE
+ WHEN (x.memory_ex2 / v.n_float) - POWER(x.memory_sum / v.n_float, 2) < 0
+ THEN 0
+ ELSE (x.memory_ex2 / v.n_float) - POWER(x.memory_sum / v.n_float, 2)
+ END
+ ) /
+ NULLIF(x.memory_sum / v.n_float, 0)
+ ),
+ min_physical_reads_mb =
+ CONVERT(decimal(38, 6), x.physical_reads_min * 8.0 / 1024.0),
+ avg_physical_reads_mb =
+ CONVERT(decimal(38, 6), x.physical_reads_sum / v.n_float * 8.0 / 1024.0),
+ max_physical_reads_mb =
+ CONVERT(decimal(38, 6), x.physical_reads_max * 8.0 / 1024.0),
+ physical_reads_volatility =
+ CONVERT
+ (
+ decimal(19, 4),
+ SQRT
+ (
+ CASE
+ WHEN (x.physical_reads_ex2 / v.n_float) - POWER(x.physical_reads_sum / v.n_float, 2) < 0
+ THEN 0
+ ELSE (x.physical_reads_ex2 / v.n_float) - POWER(x.physical_reads_sum / v.n_float, 2)
+ END
+ ) /
+ NULLIF(x.physical_reads_sum / v.n_float, 0)
+ ),
+ min_writes_mb =
+ CONVERT(decimal(38, 6), x.writes_min * 8.0 / 1024.0),
+ avg_writes_mb =
+ CONVERT(decimal(38, 6), x.writes_sum / v.n_float * 8.0 / 1024.0),
+ max_writes_mb =
+ CONVERT(decimal(38, 6), x.writes_max * 8.0 / 1024.0),
+ writes_volatility =
+ CONVERT
+ (
+ decimal(19, 4),
+ SQRT
+ (
+ CASE
+ WHEN (x.writes_ex2 / v.n_float) - POWER(x.writes_sum / v.n_float, 2) < 0
+ THEN 0
+ ELSE (x.writes_ex2 / v.n_float) - POWER(x.writes_sum / v.n_float, 2)
+ END
+ ) /
+ NULLIF(x.writes_sum / v.n_float, 0)
+ ),
+ min_tempdb_mb =
+ CONVERT(decimal(38, 6), x.tempdb_min * 8.0 / 1024.0),
+ avg_tempdb_mb =
+ CONVERT(decimal(38, 6), x.tempdb_sum / v.n_float * 8.0 / 1024.0),
+ max_tempdb_mb =
+ CONVERT(decimal(38, 6), x.tempdb_max * 8.0 / 1024.0),
+ tempdb_volatility =
+ CONVERT
+ (
+ decimal(19, 4),
+ SQRT
+ (
+ CASE
+ WHEN (x.tempdb_ex2 / v.n_float) - POWER(x.tempdb_sum / v.n_float, 2) < 0
+ THEN 0
+ ELSE (x.tempdb_ex2 / v.n_float) - POWER(x.tempdb_sum / v.n_float, 2)
+ END
+ ) /
+ NULLIF(x.tempdb_sum / v.n_float, 0)
+ ),
+ x.max_dop
+FROM
+(
+ SELECT
+ qsq.query_hash,
+ qsp.query_plan_hash,
+ query_count =
+ COUNT_BIG(DISTINCT CASE WHEN qsrs.execution_type = 0 THEN qsp.query_id END),
+ plan_count =
+ COUNT_BIG(DISTINCT CASE WHEN qsrs.execution_type = 0 THEN qsp.plan_id END),
+ last_execution_time =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.last_execution_time END),
+ n =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions ELSE 0 END),
+ aborted_executions =
+ SUM(CASE WHEN qsrs.execution_type = 3 THEN qsrs.count_executions ELSE 0 END),
+ exception_executions =
+ SUM(CASE WHEN qsrs.execution_type = 4 THEN qsrs.count_executions ELSE 0 END),
+ cpu_sum =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_cpu_time * qsrs.count_executions END),
+ cpu_ex2 =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_cpu_time * qsrs.stdev_cpu_time + qsrs.avg_cpu_time * qsrs.avg_cpu_time) END),
+ cpu_min =
+ MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_cpu_time END),
+ cpu_max =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_cpu_time END),
+ duration_sum =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_duration * qsrs.count_executions END),
+ duration_ex2 =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_duration * qsrs.stdev_duration + qsrs.avg_duration * qsrs.avg_duration) END),
+ duration_min =
+ MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_duration END),
+ duration_max =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_duration END),
+ rows_sum =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_rowcount * qsrs.count_executions END),
+ rows_ex2 =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_rowcount * qsrs.stdev_rowcount + qsrs.avg_rowcount * qsrs.avg_rowcount) END),
+ rows_min =
+ MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_rowcount END),
+ rows_max =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_rowcount END),
+ memory_sum =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_query_max_used_memory * qsrs.count_executions END),
+ memory_ex2 =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_query_max_used_memory * qsrs.stdev_query_max_used_memory + qsrs.avg_query_max_used_memory * qsrs.avg_query_max_used_memory) END),
+ memory_min =
+ MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_query_max_used_memory END),
+ memory_max =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_query_max_used_memory END),
+ physical_reads_sum =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_physical_io_reads * qsrs.count_executions END),
+ physical_reads_ex2 =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_physical_io_reads * qsrs.stdev_physical_io_reads + qsrs.avg_physical_io_reads * qsrs.avg_physical_io_reads) END),
+ physical_reads_min =
+ MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_physical_io_reads END),
+ physical_reads_max =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_physical_io_reads END),
+ writes_sum =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_logical_io_writes * qsrs.count_executions END),
+ writes_ex2 =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_logical_io_writes * qsrs.stdev_logical_io_writes + qsrs.avg_logical_io_writes * qsrs.avg_logical_io_writes) END),
+ writes_min =
+ MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_logical_io_writes END),
+ writes_max =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_logical_io_writes END),' +
+ CASE
+ WHEN @new = 1
+ THEN N'
+ tempdb_sum =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_tempdb_space_used * qsrs.count_executions END),
+ tempdb_ex2 =
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_tempdb_space_used * qsrs.stdev_tempdb_space_used + qsrs.avg_tempdb_space_used * qsrs.avg_tempdb_space_used) END),
+ tempdb_min =
+ MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_tempdb_space_used END),
+ tempdb_max =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_tempdb_space_used END),'
+ ELSE N'
+ /*NULL, not 0: a zero would render in variance_metrics as a real
+ measurement of no tempdb use, when the truth is the columns
+ do not exist before 2017. NULL attributes drop out of the XML.*/
+ tempdb_sum =
+ CONVERT(float, NULL),
+ tempdb_ex2 =
+ CONVERT(float, NULL),
+ tempdb_min =
+ CONVERT(float, NULL),
+ tempdb_max =
+ CONVERT(float, NULL),'
+ END + N'
+ max_dop =
+ MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_dop END)
+ FROM ' + @database_name_quoted + N'.sys.query_store_runtime_stats AS qsrs
+ JOIN ' + @database_name_quoted + N'.sys.query_store_plan AS qsp
+ ON qsp.plan_id = qsrs.plan_id
+ JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq
+ ON qsq.query_id = qsp.query_id
+ WHERE EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_intervals AS pi
+ WHERE pi.runtime_stats_interval_id = qsrs.runtime_stats_interval_id
+ )
+ GROUP BY
+ qsq.query_hash,
+ qsp.query_plan_hash
+ HAVING
+ SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions ELSE 0 END) > 0
+) AS x
+CROSS APPLY
+(
+ VALUES
+ (CONVERT(float, x.n))
+) AS v (n_float)
+OPTION(RECOMPILE, HASH JOIN);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ INSERT
+ #ps_shape_stats WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ query_count,
+ plan_count,
+ last_execution_time,
+ total_executions,
+ aborted_executions,
+ exception_executions,
+ total_cpu_ms,
+ min_cpu_ms,
+ avg_cpu_ms,
+ max_cpu_ms,
+ cpu_volatility,
+ total_duration_ms,
+ min_duration_ms,
+ avg_duration_ms,
+ max_duration_ms,
+ duration_volatility,
+ min_rows,
+ avg_rows,
+ max_rows,
+ rows_volatility,
+ min_memory_mb,
+ avg_memory_mb,
+ max_memory_mb,
+ memory_volatility,
+ min_physical_reads_mb,
+ avg_physical_reads_mb,
+ max_physical_reads_mb,
+ physical_reads_volatility,
+ min_writes_mb,
+ avg_writes_mb,
+ max_writes_mb,
+ writes_volatility,
+ min_tempdb_mb,
+ avg_tempdb_mb,
+ max_tempdb_mb,
+ tempdb_volatility,
+ max_dop
+ )
+ EXECUTE sys.sp_executesql
+ @sql;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ SET STATISTICS XML OFF;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_update,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_info,
+ N'@sql nvarchar(max),
+ @current_table nvarchar(100)',
+ @sql,
+ @current_table;
+ END;
+
+ /*Step 2: Roll up shape counts and average divergence per query_hash (static SQL)*/
+ INSERT
+ #ps_query_rollup WITH (TABLOCK)
+ (
+ query_hash,
+ shape_count,
+ min_shape_avg_cpu_ms,
+ max_shape_avg_cpu_ms
+ )
+ SELECT
+ ps.query_hash,
+ shape_count =
+ COUNT_BIG(*),
+ min_shape_avg_cpu_ms =
+ MIN(ps.avg_cpu_ms),
+ max_shape_avg_cpu_ms =
+ MAX(ps.avg_cpu_ms)
+ FROM #ps_shape_stats AS ps
+ GROUP BY
+ ps.query_hash;
+
+ /*
+ Step 3: Pick the top N shapes by volatility of the ranking metric.
+ The noise floors keep trivia out: a shape must have enough executions
+ for variance to mean anything, and must have done a measurable amount
+ of work at least once. @duration_ms takes over the work floor when set.
+ */
+ INSERT
+ #ps_interesting WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash
+ )
+ SELECT TOP (@top)
+ ps.query_hash,
+ ps.query_plan_hash
+ FROM #ps_shape_stats AS ps
+ WHERE ps.total_executions >= @ps_min_executions
+ AND
+ (
+ (
+ @duration_ms IS NOT NULL
+ AND ps.max_duration_ms >= @duration_ms
+ )
+ OR
+ (
+ @duration_ms IS NULL
+ AND
+ (
+ ps.max_cpu_ms >= 10
+ OR ps.max_duration_ms >= 100
+ )
+ )
+ )
+ ORDER BY
+ CASE @ps_rank_metric
+ WHEN N'duration' THEN ps.duration_volatility
+ WHEN N'physical reads' THEN ps.physical_reads_volatility
+ WHEN N'writes' THEN ps.writes_volatility
+ WHEN N'memory' THEN ps.memory_volatility
+ WHEN N'rows' THEN ps.rows_volatility
+ WHEN N'tempdb' THEN ps.tempdb_volatility
+ ELSE ps.cpu_volatility
+ END DESC,
+ ps.total_cpu_ms DESC,
+ ps.query_hash,
+ ps.query_plan_hash;
+
+ /*Step 3b: Shape volatility summary, returned before the details*/
+ SELECT
+ total_shapes =
+ (
+ SELECT
+ COUNT_BIG(*)
+ FROM #ps_shape_stats AS ps
+ ),
+ shapes_past_floors =
+ (
+ SELECT
+ COUNT_BIG(*)
+ FROM #ps_shape_stats AS ps
+ WHERE ps.total_executions >= @ps_min_executions
+ AND
+ (
+ (
+ @duration_ms IS NOT NULL
+ AND ps.max_duration_ms >= @duration_ms
+ )
+ OR
+ (
+ @duration_ms IS NULL
+ AND
+ (
+ ps.max_cpu_ms >= 10
+ OR ps.max_duration_ms >= 100
+ )
+ )
+ )
+ ),
+ surfaced_shapes =
+ (
+ SELECT
+ COUNT_BIG(*)
+ FROM #ps_interesting AS i
+ ),
+ multi_shape_query_hashes =
+ (
+ SELECT
+ COUNT_BIG(*)
+ FROM #ps_query_rollup AS qr
+ WHERE qr.shape_count > 1
+ ),
+ ranked_on =
+ @ps_rank_metric + N' volatility';
+
+ /*
+ Step 4: Stage query and plan ids for the interesting shapes.
+ Window-scoped to regular executions so the id lists agree with
+ query_count and plan_count in the shape statistics.
+ */
+ SELECT
+ @current_table = 'inserting #ps_id_staging',
+ @sql = @isolation_level;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ EXECUTE sys.sp_executesql
+ @troubleshoot_insert,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ SET STATISTICS XML ON;
+ END;
+
+ SELECT
+ @sql += N'
+SELECT DISTINCT
+ qsq.query_hash,
+ qsp.query_plan_hash,
+ qsp.query_id,
+ qsp.plan_id
+FROM ' + @database_name_quoted + N'.sys.query_store_plan AS qsp
+JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq
+ ON qsq.query_id = qsp.query_id
+JOIN #ps_interesting AS i
+ ON i.query_hash = qsq.query_hash
+ AND i.query_plan_hash = qsp.query_plan_hash
+WHERE EXISTS
+(
+ SELECT
+ 1/0
+ FROM ' + @database_name_quoted + N'.sys.query_store_runtime_stats AS qsrs
+ JOIN #ps_intervals AS pi
+ ON pi.runtime_stats_interval_id = qsrs.runtime_stats_interval_id
+ WHERE qsrs.plan_id = qsp.plan_id
+ AND qsrs.execution_type = 0
+)
+OPTION(RECOMPILE);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ INSERT
+ #ps_id_staging WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ query_id,
+ plan_id
+ )
+ EXECUTE sys.sp_executesql
+ @sql;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ SET STATISTICS XML OFF;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_update,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_info,
+ N'@sql nvarchar(max),
+ @current_table nvarchar(100)',
+ @sql,
+ @current_table;
+ END;
+
+ /*Step 4b: Representative query text per shape (the most-executed query_id in the window)*/
+ SELECT
+ @current_table = 'inserting #ps_representative_text',
+ @sql = @isolation_level;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ EXECUTE sys.sp_executesql
+ @troubleshoot_insert,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ SET STATISTICS XML ON;
+ END;
+
+ SELECT
+ @sql += N'
+SELECT
+ ranked.query_hash,
+ ranked.query_plan_hash,
+ qsqt.query_sql_text,
+ ranked.rn
+FROM
+(
+ SELECT
+ s.query_hash,
+ s.query_plan_hash,
+ qsq.query_text_id,
+ rn =
+ ROW_NUMBER() OVER
+ (
+ PARTITION BY
+ s.query_hash,
+ s.query_plan_hash
+ ORDER BY
+ SUM(qsrs.count_executions) DESC
+ )
+ FROM #ps_id_staging AS s
+ JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq
+ ON qsq.query_id = s.query_id
+ JOIN ' + @database_name_quoted + N'.sys.query_store_runtime_stats AS qsrs
+ ON qsrs.plan_id = s.plan_id
+ WHERE EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_intervals AS pi
+ WHERE pi.runtime_stats_interval_id = qsrs.runtime_stats_interval_id
+ )
+ AND qsrs.execution_type = 0
+ GROUP BY
+ s.query_hash,
+ s.query_plan_hash,
+ qsq.query_text_id
+) AS ranked
+JOIN ' + @database_name_quoted + N'.sys.query_store_query_text AS qsqt
+ ON qsqt.query_text_id = ranked.query_text_id
+WHERE ranked.rn = 1
+OPTION(RECOMPILE);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ INSERT
+ #ps_representative_text WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ query_sql_text,
+ rn
+ )
+ EXECUTE sys.sp_executesql
+ @sql;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ SET STATISTICS XML OFF;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_update,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_info,
+ N'@sql nvarchar(max),
+ @current_table nvarchar(100)',
+ @sql,
+ @current_table;
+ END;
+
+ /*Step 4c: Object ids per shape, for module name resolution*/
+ SELECT
+ @sql = @isolation_level;
+
+ SELECT
+ @sql += N'
+SELECT DISTINCT
+ s.query_hash,
+ s.query_plan_hash,
+ qsq.object_id
+FROM #ps_id_staging AS s
+JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq
+ ON qsq.query_id = s.query_id
+WHERE qsq.object_id > 0
+OPTION(RECOMPILE);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ INSERT
+ #ps_object_staging WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ object_id
+ )
+ EXECUTE sys.sp_executesql
+ @sql;
+
+ /*Step 5: Wait stats per shape (SQL 2017+ only, two-stage approach)*/
+ IF
+ (
+ @new = 1
+ AND @query_store_waits_enabled = 1
+ )
+ BEGIN
+ /*Stage 1: Dynamic SQL populates staging table*/
+ SELECT
+ @current_table = 'inserting #ps_wait_staging',
+ @sql = @isolation_level;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ EXECUTE sys.sp_executesql
+ @troubleshoot_insert,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ SET STATISTICS XML ON;
+ END;
+
+ SELECT
+ @sql += N'
+SELECT
+ s.query_hash,
+ s.query_plan_hash,
+ qsws.wait_category_desc,
+ total_wait_ms =
+ SUM(qsws.total_query_wait_time_ms),
+ rn =
+ ROW_NUMBER() OVER
+ (
+ PARTITION BY
+ s.query_hash,
+ s.query_plan_hash
+ ORDER BY
+ SUM(qsws.total_query_wait_time_ms) DESC
+ )
+FROM ' + @database_name_quoted + N'.sys.query_store_wait_stats AS qsws
+JOIN #ps_id_staging AS s
+ ON qsws.plan_id = s.plan_id
+WHERE EXISTS
+(
+ SELECT
+ 1/0
+ FROM #ps_intervals AS pi
+ WHERE pi.runtime_stats_interval_id = qsws.runtime_stats_interval_id
+)
+GROUP BY
+ s.query_hash,
+ s.query_plan_hash,
+ qsws.wait_category_desc
+OPTION(RECOMPILE);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ INSERT
+ #ps_wait_staging WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ wait_category_desc,
+ total_wait_ms,
+ rn
+ )
+ EXECUTE sys.sp_executesql
+ @sql;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ SET STATISTICS XML OFF;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_update,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_info,
+ N'@sql nvarchar(max),
+ @current_table nvarchar(100)',
+ @sql,
+ @current_table;
+ END;
+
+ /*Stage 2: Aggregate top 3 waits per shape (static SQL, XML PATH)*/
+ INSERT
+ #ps_shape_waits WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ top_waits
+ )
+ SELECT
+ ws.query_hash,
+ ws.query_plan_hash,
+ top_waits =
+ STUFF
+ (
+ (
+ SELECT
+ N', ' +
+ ws2.wait_category_desc +
+ N' (' +
+ CONVERT(nvarchar(20), CONVERT(bigint, ws2.total_wait_ms)) +
+ N' ms)'
+ FROM #ps_wait_staging AS ws2
+ WHERE ws2.query_hash = ws.query_hash
+ AND ws2.query_plan_hash = ws.query_plan_hash
+ AND ws2.rn <= 3
+ ORDER BY
+ ws2.total_wait_ms DESC
+ FOR
+ XML PATH(N''),
+ TYPE
+ ).value(N'./text()[1]', N'nvarchar(max)'),
+ 1,
+ 2,
+ N''
+ )
+ FROM
+ (
+ SELECT DISTINCT
+ ws.query_hash,
+ ws.query_plan_hash
+ FROM #ps_wait_staging AS ws
+ ) AS ws;
+ END; /*End wait stats*/
+
+ /*Step 5b: Flag shapes the Parameter Sensitive Plan feature already touched (SQL 2022+ only)*/
+ IF @sql_2022_views = 1
+ BEGIN
+ SELECT
+ @current_table = 'inserting #ps_variant_flags',
+ @sql = @isolation_level;
+
+ SELECT
+ @sql += N'
+SELECT DISTINCT
+ s.query_hash,
+ s.query_plan_hash,
+ has_variants =
+ CONVERT(bit, 1)
+FROM #ps_id_staging AS s
+WHERE EXISTS
+(
+ SELECT
+ 1/0
+ FROM ' + @database_name_quoted + N'.sys.query_store_query_variant AS qsqv
+ WHERE qsqv.query_variant_query_id = s.query_id
+ OR qsqv.parent_query_id = s.query_id
+)
+OPTION(RECOMPILE);' + @nc10;
+
+ IF @debug = 1
+ BEGIN
+ PRINT LEN(@sql);
+ PRINT @sql;
+ END;
+
+ INSERT
+ #ps_variant_flags WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ has_variants
+ )
+ EXECUTE sys.sp_executesql
+ @sql;
+ END; /*End variant flags*/
+
+ /*Step 5c: Aggregate ids with XML PATH and resolve object names (static SQL)*/
+ INSERT
+ #ps_identifiers WITH (TABLOCK)
+ (
+ query_hash,
+ query_plan_hash,
+ query_id_list,
+ plan_id_list,
+ object_name
+ )
+ SELECT
+ i.query_hash,
+ i.query_plan_hash,
+ query_id_list =
+ STUFF
+ (
+ (
+ SELECT DISTINCT
+ N', ' +
+ CONVERT(nvarchar(20), s.query_id)
+ FROM #ps_id_staging AS s
+ WHERE s.query_hash = i.query_hash
+ AND s.query_plan_hash = i.query_plan_hash
+ ORDER BY
+ N', ' +
+ CONVERT(nvarchar(20), s.query_id)
+ FOR
+ XML PATH(N''),
+ TYPE
+ ).value(N'./text()[1]', N'nvarchar(max)'),
+ 1,
+ 2,
+ N''
+ ),
+ plan_id_list =
+ STUFF
+ (
+ (
+ SELECT DISTINCT
+ N', ' +
+ CONVERT(nvarchar(20), s.plan_id)
+ FROM #ps_id_staging AS s
+ WHERE s.query_hash = i.query_hash
+ AND s.query_plan_hash = i.query_plan_hash
+ ORDER BY
+ N', ' +
+ CONVERT(nvarchar(20), s.plan_id)
+ FOR
+ XML PATH(N''),
+ TYPE
+ ).value(N'./text()[1]', N'nvarchar(max)'),
+ 1,
+ 2,
+ N''
+ ),
+ object_name =
+ ISNULL
+ (
+ QUOTENAME
+ (
+ OBJECT_SCHEMA_NAME
+ (
+ (
+ SELECT TOP (1)
+ os.object_id
+ FROM #ps_object_staging AS os
+ WHERE os.query_hash = i.query_hash
+ AND os.query_plan_hash = i.query_plan_hash
+ ORDER BY
+ os.object_id
+ ),
+ @database_id
+ )
+ ) +
+ N'.' +
+ QUOTENAME
+ (
+ OBJECT_NAME
+ (
+ (
+ SELECT TOP (1)
+ os.object_id
+ FROM #ps_object_staging AS os
+ WHERE os.query_hash = i.query_hash
+ AND os.query_plan_hash = i.query_plan_hash
+ ORDER BY
+ os.object_id
+ ),
+ @database_id
+ )
+ ),
+ CASE
+ WHEN EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_object_staging AS os
+ WHERE os.query_hash = i.query_hash
+ AND os.query_plan_hash = i.query_plan_hash
+ )
+ THEN N'Unknown object_id'
+ ELSE N'Adhoc'
+ END
+ )
+ FROM #ps_interesting AS i;
+
+ /*Step 6: Assemble output (static SQL, no plans yet)*/
+ SELECT
+ @current_table = 'inserting #ps_output',
+ @sql = N'';
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ EXECUTE sys.sp_executesql
+ @troubleshoot_insert,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ SET STATISTICS XML ON;
+ END;
+
+ INSERT
+ #ps_output WITH (TABLOCK)
+ (
+ object_name,
+ query_sql_text,
+ top_waits,
+ query_hash,
+ query_plan_hash,
+ query_count,
+ plan_count,
+ shapes_for_query_hash,
+ query_id_list,
+ plan_id_list,
+ ranked_on,
+ volatility_score,
+ signals,
+ total_executions,
+ aborted_executions,
+ exception_executions,
+ min_cpu_ms,
+ avg_cpu_ms,
+ max_cpu_ms,
+ cpu_volatility,
+ min_duration_ms,
+ avg_duration_ms,
+ max_duration_ms,
+ duration_volatility,
+ min_rows,
+ avg_rows,
+ max_rows,
+ rows_volatility,
+ last_execution_time,
+ variance_metrics
+ )
+ SELECT
+ qi.object_name,
+ rt.query_sql_text,
+ top_waits =
+ CASE
+ WHEN @new = 1
+ AND @query_store_waits_enabled = 1
+ THEN qw.top_waits
+ END,
+ ps.query_hash,
+ ps.query_plan_hash,
+ ps.query_count,
+ ps.plan_count,
+ shapes_for_query_hash =
+ ISNULL(qr.shape_count, 1),
+ qi.query_id_list,
+ qi.plan_id_list,
+ ranked_on =
+ @ps_rank_metric + N' volatility',
+ volatility_score =
+ CASE @ps_rank_metric
+ WHEN N'duration' THEN ps.duration_volatility
+ WHEN N'physical reads' THEN ps.physical_reads_volatility
+ WHEN N'writes' THEN ps.writes_volatility
+ WHEN N'memory' THEN ps.memory_volatility
+ WHEN N'rows' THEN ps.rows_volatility
+ WHEN N'tempdb' THEN ps.tempdb_volatility
+ ELSE ps.cpu_volatility
+ END,
+ signals =
+ STUFF
+ (
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN sw.cpu_ratio >= 20
+ AND ps.max_cpu_ms >= 10
+ AND sw.cpu_ratio >= sw.rows_ratio * 10
+ THEN N'parameter sensitive (cpu ' +
+ CONVERT(nvarchar(20), CONVERT(bigint, sw.cpu_ratio)) +
+ N'x, rows ' +
+ CONVERT(nvarchar(20), CONVERT(bigint, sw.rows_ratio)) +
+ N'x)'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN sw.rows_ratio >= 20
+ AND sw.cpu_ratio <= sw.rows_ratio * 2
+ THEN N'row driven (cpu ' +
+ CONVERT(nvarchar(20), CONVERT(bigint, sw.cpu_ratio)) +
+ N'x, rows ' +
+ CONVERT(nvarchar(20), CONVERT(bigint, sw.rows_ratio)) +
+ N'x)'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN sw.duration_ratio >= 20
+ AND ps.max_duration_ms >= 1000
+ AND sw.cpu_ratio < 3
+ THEN N'intermittent waits (duration ' +
+ CONVERT(nvarchar(20), CONVERT(bigint, sw.duration_ratio)) +
+ N'x, cpu ' +
+ CONVERT(nvarchar(20), CONVERT(decimal(5, 1), sw.cpu_ratio)) +
+ N'x)'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN sw.memory_ratio >= 10
+ AND ps.max_memory_mb >= 8
+ THEN N'memory grant swings (' +
+ CONVERT(nvarchar(20), CONVERT(bigint, sw.memory_ratio)) +
+ N'x)'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN sw.tempdb_ratio >= 10
+ AND ps.max_tempdb_mb >= 8
+ THEN N'tempdb swings (' +
+ CONVERT(nvarchar(20), CONVERT(bigint, sw.tempdb_ratio)) +
+ N'x)'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN ps.aborted_executions > 0
+ THEN N'timed out or cancelled ' +
+ CONVERT(nvarchar(20), ps.aborted_executions) +
+ N' times'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN ps.exception_executions > 0
+ THEN N'errored ' +
+ CONVERT(nvarchar(20), ps.exception_executions) +
+ N' times'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN vf.has_variants = 1
+ THEN N'psp variants involved'
+ END,
+ N''
+ ) +
+ ISNULL
+ (
+ N' | ' +
+ CASE
+ WHEN qr.shape_count > 1
+ THEN N'other plan shapes exist (' +
+ CONVERT(nvarchar(20), qr.shape_count) +
+ N' total, shape avg cpu ' +
+ CONVERT(nvarchar(20), CONVERT(decimal(19, 1), qr.min_shape_avg_cpu_ms)) +
+ N' to ' +
+ CONVERT(nvarchar(20), CONVERT(decimal(19, 1), qr.max_shape_avg_cpu_ms)) +
+ N' ms)'
+ END,
+ N''
+ ),
+ 1,
+ 3,
+ N''
+ ),
+ ps.total_executions,
+ ps.aborted_executions,
+ ps.exception_executions,
+ ps.min_cpu_ms,
+ ps.avg_cpu_ms,
+ ps.max_cpu_ms,
+ ps.cpu_volatility,
+ ps.min_duration_ms,
+ ps.avg_duration_ms,
+ ps.max_duration_ms,
+ ps.duration_volatility,
+ ps.min_rows,
+ ps.avg_rows,
+ ps.max_rows,
+ ps.rows_volatility,
+ ps.last_execution_time,
+ variance_metrics =
+ (
+ SELECT
+ [memory/@min_mb] = ps.min_memory_mb,
+ [memory/@avg_mb] = ps.avg_memory_mb,
+ [memory/@max_mb] = ps.max_memory_mb,
+ [memory/@volatility] = ps.memory_volatility,
+ [physical_reads/@min_mb] = ps.min_physical_reads_mb,
+ [physical_reads/@avg_mb] = ps.avg_physical_reads_mb,
+ [physical_reads/@max_mb] = ps.max_physical_reads_mb,
+ [physical_reads/@volatility] = ps.physical_reads_volatility,
+ [writes/@min_mb] = ps.min_writes_mb,
+ [writes/@avg_mb] = ps.avg_writes_mb,
+ [writes/@max_mb] = ps.max_writes_mb,
+ [writes/@volatility] = ps.writes_volatility,
+ [tempdb/@min_mb] = ps.min_tempdb_mb,
+ [tempdb/@avg_mb] = ps.avg_tempdb_mb,
+ [tempdb/@max_mb] = ps.max_tempdb_mb,
+ [tempdb/@volatility] = ps.tempdb_volatility,
+ [parallelism/@max_dop] = ps.max_dop
+ FOR
+ XML
+ PATH(N'metrics'),
+ TYPE
+ )
+ FROM #ps_shape_stats AS ps
+ JOIN #ps_interesting AS i
+ ON i.query_hash = ps.query_hash
+ AND i.query_plan_hash = ps.query_plan_hash
+ CROSS APPLY
+ (
+ /*
+ max/min ratios with clamped mins, not (max - min) / avg: when a
+ third of executions are slow, the average inflates and the swing
+ reads small even when max/min is four orders of magnitude apart,
+ which is exactly the case this mode exists to catch
+ */
+ VALUES
+ (
+ ps.max_cpu_ms /
+ CASE
+ WHEN ps.min_cpu_ms < 0.001
+ THEN 0.001
+ ELSE ps.min_cpu_ms
+ END,
+ ps.max_duration_ms /
+ CASE
+ WHEN ps.min_duration_ms < 0.001
+ THEN 0.001
+ ELSE ps.min_duration_ms
+ END,
+ CONVERT(decimal(38, 6), ps.max_rows) /
+ CASE
+ WHEN ps.min_rows < 1
+ THEN 1
+ ELSE ps.min_rows
+ END,
+ ps.max_memory_mb /
+ CASE
+ WHEN ps.min_memory_mb < 0.001
+ THEN 0.001
+ ELSE ps.min_memory_mb
+ END,
+ ps.max_tempdb_mb /
+ CASE
+ WHEN ps.min_tempdb_mb < 0.001
+ THEN 0.001
+ ELSE ps.min_tempdb_mb
+ END
+ )
+ ) AS sw (cpu_ratio, duration_ratio, rows_ratio, memory_ratio, tempdb_ratio)
+ LEFT JOIN #ps_representative_text AS rt
+ ON rt.query_hash = ps.query_hash
+ AND rt.query_plan_hash = ps.query_plan_hash
+ AND rt.rn = 1
+ LEFT JOIN #ps_identifiers AS qi
+ ON qi.query_hash = ps.query_hash
+ AND qi.query_plan_hash = ps.query_plan_hash
+ LEFT JOIN #ps_shape_waits AS qw
+ ON qw.query_hash = ps.query_hash
+ AND qw.query_plan_hash = ps.query_plan_hash
+ LEFT JOIN #ps_variant_flags AS vf
+ ON vf.query_hash = ps.query_hash
+ AND vf.query_plan_hash = ps.query_plan_hash
+ LEFT JOIN #ps_query_rollup AS qr
+ ON qr.query_hash = ps.query_hash;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ SET STATISTICS XML OFF;
+
+ EXECUTE sys.sp_executesql
+ @troubleshoot_update,
+ N'@current_table nvarchar(100)',
+ @current_table;
+ END;
+
+ /*Step 7: Final output with plans (dynamic SQL for OUTER APPLY)*/
+ SELECT
+ @current_table = 'selecting parameter sensitivity results',
+ @sql = @isolation_level;
+
+ IF @troubleshoot_performance = 1
+ BEGIN
+ EXECUTE sys.sp_executesql
+ @troubleshoot_insert,
+ N'@current_table nvarchar(100)',
+ @current_table;
+
+ SET STATISTICS XML ON;
+ END;
+
+ SELECT
+ @sql += N'
+SELECT
+ database_name =
+ @database_name,
+ start_date =
+ ' +
+ CASE
+ WHEN @timezone IS NOT NULL
+ THEN N'@start_date AT TIME ZONE @timezone'
+ ELSE N'SWITCHOFFSET(@start_date, @utc_offset_string)'
+ END + N',
+ end_date =
+ ' +
+ CASE
+ WHEN @timezone IS NOT NULL
+ THEN N'@end_date AT TIME ZONE @timezone'
+ ELSE N'SWITCHOFFSET(@end_date, @utc_offset_string)'
+ END + N',
+ o.object_name,
+ query_sql_text =
+ (
+ SELECT
+ [processing-instruction(query)] =
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
+ o.query_sql_text COLLATE Latin1_General_BIN2,
+ NCHAR(31),N''?''),NCHAR(30),N''?''),NCHAR(29),N''?''),NCHAR(28),N''?''),NCHAR(27),N''?''),NCHAR(26),N''?''),NCHAR(25),N''?''),NCHAR(24),N''?''),NCHAR(23),N''?''),NCHAR(22),N''?''),
+ NCHAR(21),N''?''),NCHAR(20),N''?''),NCHAR(19),N''?''),NCHAR(18),N''?''),NCHAR(17),N''?''),NCHAR(16),N''?''),NCHAR(15),N''?''),NCHAR(14),N''?''),NCHAR(12),N''?''),
+ NCHAR(11),N''?''),NCHAR(8),N''?''),NCHAR(7),N''?''),NCHAR(6),N''?''),NCHAR(5),N''?''),NCHAR(4),N''?''),NCHAR(3),N''?''),NCHAR(2),N''?''),NCHAR(1),N''?''),NCHAR(0),N'''')
+ FOR XML
+ PATH(N''''),
+ TYPE
+ ),
+ query_plan =
+ TRY_CONVERT(xml, qp.query_plan),
+ ' +
+ CASE
+ WHEN @new = 1
+ AND @query_store_waits_enabled = 1
+ THEN N'o.top_waits,
+ '
+ ELSE N''
+ END + N'o.query_hash,
+ o.query_plan_hash,
+ o.query_count,
+ o.plan_count,
+ o.shapes_for_query_hash,
+ o.query_id_list,
+ o.plan_id_list,
+ o.ranked_on,
+ o.volatility_score,
+ o.signals,
+ o.total_executions,
+ o.aborted_executions,
+ o.exception_executions,
+ o.min_cpu_ms,
+ o.avg_cpu_ms,
+ o.max_cpu_ms,
+ o.cpu_volatility,
+ o.min_duration_ms,
+ o.avg_duration_ms,
+ o.max_duration_ms,
+ o.duration_volatility,
+ o.min_rows,
+ o.avg_rows,
+ o.max_rows,
+ o.rows_volatility,
+ last_execution_time =
+ ' +
+ CASE
+ WHEN @timezone IS NOT NULL
+ THEN N'o.last_execution_time AT TIME ZONE @timezone'
+ ELSE N'SWITCHOFFSET(o.last_execution_time, @utc_offset_string)'
+ END + N',
+ o.variance_metrics
+FROM #ps_output AS o
+OUTER APPLY
+(
+ SELECT
+ qp0.*
+ FROM
+ (
+ SELECT
+ n = ROW_NUMBER() OVER
+ (
+ ORDER BY
+ qsp.last_execution_time DESC
+ ),
+ qsp.query_plan
+ FROM ' + @database_name_quoted + N'.sys.query_store_plan AS qsp
+ WHERE EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_id_staging AS s
+ WHERE qsp.plan_id = s.plan_id
+ AND o.query_hash = s.query_hash
+ AND o.query_plan_hash = s.query_plan_hash
)
) AS qp0
WHERE qp0.n = 1
) AS qp
-' +
- CASE
- WHEN @primary_window IS NULL
- THEN N''
- WHEN LOWER(@primary_window) LIKE N'b%'
- THEN N'WHERE o.primary_window LIKE N''Business%''' + @nc10
- WHEN LOWER(@primary_window) LIKE N'o%'
- THEN N'WHERE o.primary_window LIKE N''Off-hours%''' + @nc10
- WHEN LOWER(@primary_window) LIKE N'w%'
- THEN N'WHERE o.primary_window LIKE N''Weekend%''' + @nc10
- ELSE N''
- END + N'ORDER BY
- o.impact_score DESC,
- ' +
- CASE LOWER(@sort_order)
- WHEN 'duration' THEN N'o.duration_share'
- WHEN 'physical reads' THEN N'o.physical_reads_share'
- WHEN 'writes' THEN N'o.writes_share'
- WHEN 'memory' THEN N'o.memory_share'
- WHEN 'executions' THEN N'o.executions_share'
- ELSE N'o.cpu_share'
- END + N' DESC
+ORDER BY
+ o.volatility_score DESC,
+ o.query_hash,
+ o.query_plan_hash
OPTION(RECOMPILE);' + @nc10;
IF @debug = 1
@@ -6899,7 +8739,7 @@ OPTION(RECOMPILE);' + @nc10;
BEGIN
RETURN;
END;
-END; /*End @find_high_impact*/
+END; /*End @find_parameter_sensitive*/
/*
Get filters ready, or whatever
@@ -14251,6 +16091,8 @@ BEGIN
@find_high_impact,
primary_window =
@primary_window,
+ find_parameter_sensitive =
+ @find_parameter_sensitive,
help =
@help,
debug =
@@ -14489,7 +16331,8 @@ BEGIN
'#requested_but_skipped_databases is empty';
END;
- IF @find_high_impact = 0
+ IF @find_high_impact = 0
+ AND @find_parameter_sensitive = 0
BEGIN
IF EXISTS
(
@@ -14653,7 +16496,8 @@ BEGIN
'#include_query_hashes is empty';
END;
- IF @find_high_impact = 0
+ IF @find_high_impact = 0
+ AND @find_parameter_sensitive = 0
BEGIN
IF EXISTS
(
@@ -15066,7 +16910,8 @@ BEGIN
'#database_query_store_options is empty';
END;
- IF @find_high_impact = 0
+ IF @find_high_impact = 0
+ AND @find_parameter_sensitive = 0
BEGIN
IF EXISTS
@@ -15550,7 +17395,8 @@ BEGIN
'#troubleshoot_performance is empty';
END;
- IF @find_high_impact = 0
+ IF @find_high_impact = 0
+ AND @find_parameter_sensitive = 0
BEGIN
IF EXISTS
(
@@ -15931,6 +17777,298 @@ BEGIN
END;
END;
+ IF @find_parameter_sensitive = 1
+ AND OBJECT_ID('tempdb..#ps_intervals') IS NOT NULL
+ BEGIN
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_intervals AS pi
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_intervals',
+ pi.*
+ FROM #ps_intervals AS pi
+ ORDER BY
+ pi.runtime_stats_interval_id
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_intervals is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_shape_stats AS ps
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_shape_stats',
+ ps.*
+ FROM #ps_shape_stats AS ps
+ ORDER BY
+ ps.query_hash,
+ ps.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_shape_stats is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_query_rollup AS qr
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_query_rollup',
+ qr.*
+ FROM #ps_query_rollup AS qr
+ ORDER BY
+ qr.query_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_query_rollup is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_interesting AS i
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_interesting',
+ i.*
+ FROM #ps_interesting AS i
+ ORDER BY
+ i.query_hash,
+ i.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_interesting is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_id_staging AS s
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_id_staging',
+ s.*
+ FROM #ps_id_staging AS s
+ ORDER BY
+ s.query_hash,
+ s.query_plan_hash,
+ s.plan_id
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_id_staging is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_representative_text AS rt
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_representative_text',
+ rt.*
+ FROM #ps_representative_text AS rt
+ ORDER BY
+ rt.query_hash,
+ rt.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_representative_text is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_object_staging AS os
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_object_staging',
+ os.*
+ FROM #ps_object_staging AS os
+ ORDER BY
+ os.query_hash,
+ os.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_object_staging is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_wait_staging AS ws
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_wait_staging',
+ ws.*
+ FROM #ps_wait_staging AS ws
+ ORDER BY
+ ws.query_hash,
+ ws.query_plan_hash,
+ ws.rn
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_wait_staging is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_shape_waits AS sw
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_shape_waits',
+ sw.*
+ FROM #ps_shape_waits AS sw
+ ORDER BY
+ sw.query_hash,
+ sw.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_shape_waits is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_variant_flags AS vf
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_variant_flags',
+ vf.*
+ FROM #ps_variant_flags AS vf
+ ORDER BY
+ vf.query_hash,
+ vf.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_variant_flags is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_identifiers AS qi
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_identifiers',
+ qi.*
+ FROM #ps_identifiers AS qi
+ ORDER BY
+ qi.query_hash,
+ qi.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_identifiers is empty';
+ END;
+
+ IF EXISTS
+ (
+ SELECT
+ 1/0
+ FROM #ps_output AS o
+ )
+ BEGIN
+ SELECT
+ table_name =
+ '#ps_output',
+ o.*
+ FROM #ps_output AS o
+ ORDER BY
+ o.query_hash,
+ o.query_plan_hash
+ OPTION(RECOMPILE);
+ END;
+ ELSE
+ BEGIN
+ SELECT
+ result =
+ '#ps_output is empty';
+ END;
+ END;
+
RETURN; /*Stop doing anything, I guess*/
END; /*End debug*/
RETURN; /*Yeah sure why not?*/
diff --git a/sp_QuickieStore/tests/README.md b/sp_QuickieStore/tests/README.md
index 6a71b1cf..1f342282 100644
--- a/sp_QuickieStore/tests/README.md
+++ b/sp_QuickieStore/tests/README.md
@@ -2,7 +2,7 @@
**Run before and after any change to `sp_QuickieStore.sql`.** Compiling proves
very little here: the procedure is ~16,000 lines that assemble a large dynamic
-SQL statement whose *shape* changes with almost every one of its 59 parameters.
+SQL statement whose *shape* changes with almost every one of its 60 parameters.
The statement that breaks is built at run time from string fragments, so it only
fails when it executes.
@@ -10,7 +10,7 @@ fails when it executes.
| Script | What it does |
| --- | --- |
-| `run_tests.py` | Builds a Query Store scratch database, then runs a parameter matrix asserting each combination executes cleanly and reaches completion, plus bidirectional filter checks. 134 assertions. |
+| `run_tests.py` | Builds a Query Store scratch database, then runs a parameter matrix asserting each combination executes cleanly and reaches completion, plus bidirectional filter checks. 157 assertions. |
```
cd sp_QuickieStore/tests
@@ -18,7 +18,7 @@ python run_tests.py --server SQL2022
```
Takes `--server` and `--password` (default `SQL2022` / the standard local sa
-password). Expect `134`.
+password). Expect `157`.
## What it actually covers
@@ -31,6 +31,10 @@ each run *executes* what it built:
- **9 `@wait_filter` values**, **4 `@execution_type_desc`**, **4 `@query_type`**.
- The **`@expert_mode` x `@format_output` grid**, which rewrites the column list,
plus several sort orders combined with expert mode.
+- **`@find_parameter_sensitive`**, the plan-shape volatility mode: ranking by
+ each volatility metric, the mode grids, floors, mode-conflict guard errors,
+ and a fixture procedure (`qs_sniff_proc`) skewed hard enough that the
+ `parameter sensitive` signal must fire on a real conviction.
On top of that, **bidirectional** assertions prove the filters actually filter
rather than being silently ignored — `@query_text_search` on a known string vs
diff --git a/sp_QuickieStore/tests/run_tests.py b/sp_QuickieStore/tests/run_tests.py
index 898335f5..73c87eab 100644
--- a/sp_QuickieStore/tests/run_tests.py
+++ b/sp_QuickieStore/tests/run_tests.py
@@ -2,7 +2,7 @@
sp_QuickieStore assertion test harness
======================================
sp_QuickieStore is ~16,000 lines that assemble a large dynamic SQL statement
-whose SHAPE changes with almost every one of its 59 parameters: @sort_order
+whose SHAPE changes with almost every one of its 60 parameters: @sort_order
alone accepts 35+ values, each producing a different ORDER BY and column set,
and @wait_filter / @execution_type_desc / @query_type / @expert_mode /
@format_output each rewrite the statement again.
@@ -199,6 +199,42 @@ def failed(self):
SET @i += 1;
END;
+-- Parameter sensitivity fixture for @find_parameter_sensitive: a skewed table
+-- (group 1 has 100k rows, groups 2-501 have one each) and a procedure compiled
+-- on a tiny group so the seek + lookup plan gets reused for the giant group.
+-- One plan shape, four orders of magnitude of cpu swing, flat-ish row counts.
+CREATE TABLE dbo.skew
+(
+ id integer NOT NULL IDENTITY PRIMARY KEY,
+ grp integer NOT NULL,
+ pad char(200) NOT NULL
+);
+
+INSERT dbo.skew WITH (TABLOCK) (grp, pad)
+SELECT TOP (100000) 1, REPLICATE('x', 200)
+FROM sys.all_columns AS ac1 CROSS JOIN sys.all_columns AS ac2;
+
+INSERT dbo.skew (grp, pad)
+SELECT TOP (500) 1 + ROW_NUMBER() OVER (ORDER BY ac1.object_id), REPLICATE('y', 200)
+FROM sys.all_columns AS ac1;
+
+CREATE INDEX ix_grp ON dbo.skew (grp);
+
+EXECUTE (N'CREATE PROCEDURE dbo.qs_sniff_proc (@grp integer) AS BEGIN SET NOCOUNT ON; DECLARE @c bigint, @p char(200); SELECT @c = COUNT_BIG(*) FROM dbo.skew AS s WHERE s.grp = @grp; SELECT TOP (10) @p = s2.pad FROM dbo.skew AS s2 WHERE s2.grp = @grp ORDER BY s2.pad; END;');
+
+DECLARE @g integer = 0;
+WHILE @g < 6
+BEGIN
+ EXECUTE dbo.qs_sniff_proc @grp = 5;
+ SET @g += 1;
+END;
+EXECUTE dbo.qs_sniff_proc @grp = 1;
+EXECUTE dbo.qs_sniff_proc @grp = 1;
+EXECUTE dbo.qs_sniff_proc @grp = 1;
+EXECUTE dbo.qs_sniff_proc @grp = 301;
+EXECUTE dbo.qs_sniff_proc @grp = 302;
+EXECUTE dbo.qs_sniff_proc @grp = 303;
+
EXECUTE sys.sp_query_store_flush_db;
"""
@@ -377,6 +413,115 @@ def bidirectional_tests(server, password, R):
result_rows(out) == 0, "got %d rows" % result_rows(out))
+PS_SUMMARY_MARKER = "multi_shape_query_hashes"
+
+
+def ps_detail_rows(stdout):
+ """Detail rows from @find_parameter_sensitive output: one per plan shape,
+ each beginning with the analyzed database name."""
+ return sum(1 for line in stdout.splitlines()
+ if line.startswith(TEST_DB + "\t"))
+
+
+def parameter_sensitive_tests(server, password, R):
+ """@find_parameter_sensitive is a takeover mode like @find_high_impact: it
+ returns its own result sets (a summary plus one row per plan shape) and
+ never reaches the footer, so completion is proven by the summary result
+ set's header instead of DONE_MARKER. The fixture's qs_sniff_proc gives it
+ a real conviction to make: one plan shape whose cpu swings four orders of
+ magnitude while rows stay flat."""
+ out, combined = run_qs(server, password, ", @find_parameter_sensitive = 1")
+ errs = find_sql_errors(combined)
+ R.check("ParamSensitive", "default run executes cleanly", not errs, str(errs[:2]))
+ R.check("ParamSensitive", "default run returns the summary result set",
+ PS_SUMMARY_MARKER in out, "summary header missing")
+ R.check("ParamSensitive", "default run returns detail rows",
+ ps_detail_rows(out) > 0, "no shapes surfaced from the skewed workload")
+ R.check("ParamSensitive", "the sniffed procedure is surfaced",
+ "qs_sniff_proc" in out, "qs_sniff_proc missing from output")
+ R.check("ParamSensitive", "the parameter sensitive signal fires",
+ "parameter sensitive (cpu" in out, "signals column missing the verdict")
+
+ # Each of these picks a different volatility column to rank by; the wait
+ # sort falls back to cpu. 'tempdb' additionally exercises the 2017+ branch.
+ for so in ("duration", "memory", "rows", "tempdb", "physical reads",
+ "cpu waits"):
+ out, combined = run_qs(server, password,
+ ", @find_parameter_sensitive = 1, "
+ "@sort_order = '%s'" % _esc(so))
+ errs = find_sql_errors(combined)
+ R.check("ParamSensitive", "@sort_order = '%s' executes cleanly" % so,
+ not errs and PS_SUMMARY_MARKER in out, str(errs[:2]))
+
+ for expert in (0, 1):
+ for fmt in (0, 1):
+ extra = (", @find_parameter_sensitive = 1, @expert_mode = %d, "
+ "@format_output = %d" % (expert, fmt))
+ out, combined = run_qs(server, password, extra)
+ errs = find_sql_errors(combined)
+ R.check("ParamSensitive",
+ "expert_mode=%d format_output=%d executes cleanly"
+ % (expert, fmt),
+ not errs and PS_SUMMARY_MARKER in out, str(errs[:2]))
+
+ out, combined = run_qs(server, password,
+ ", @find_parameter_sensitive = 1, @top = 1")
+ R.check("ParamSensitive", "@top = 1 returns exactly one shape",
+ ps_detail_rows(out) == 1, "got %d rows" % ps_detail_rows(out))
+
+ out, combined = run_qs(server, password,
+ ", @find_parameter_sensitive = 1, "
+ "@execution_count = 1000000")
+ R.check("ParamSensitive",
+ "@execution_count impossibly high: summary still returned "
+ "(positive control for the absence below)",
+ PS_SUMMARY_MARKER in out, "summary header missing")
+ R.check("ParamSensitive", "@execution_count impossibly high returns no shapes",
+ ps_detail_rows(out) == 0, "got %d rows" % ps_detail_rows(out))
+
+ for extra, what in (
+ (", @find_parameter_sensitive = 1, @find_high_impact = 1",
+ "@find_high_impact"),
+ (", @find_parameter_sensitive = 1, @get_all_databases = 1",
+ "@get_all_databases"),
+ (", @find_parameter_sensitive = 1, @log_to_table = 1",
+ "@log_to_table")):
+ out, combined = run_qs(server, password, extra)
+ R.check("ParamSensitive", "conflict with %s raises the guard error" % what,
+ "cannot be used with" in combined, "guard error missing")
+
+ # DEBUG dumps: run against a scratch database with Query Store ON but no
+ # captured queries. master's Query Store is off, which bails out long
+ # before the mode block, so it cannot reach the new dump section. This
+ # reaches the mode with an empty Query Store (covering that path too),
+ # dumps every #ps_* table as its "is empty" row, and exercises the
+ # extended guards that skip the normal-path tables the mode never
+ # creates. With no XML rows, the go-sqlcmd rendering problem the other
+ # @debug test dodges does not apply here.
+ empty_db = TEST_DB + "_empty"
+ drop_empty = ("IF DB_ID(N'%s') IS NOT NULL BEGIN "
+ "ALTER DATABASE %s SET SINGLE_USER WITH ROLLBACK IMMEDIATE; "
+ "DROP DATABASE %s; END;" % (empty_db, empty_db, empty_db))
+ _sqlcmd(server, password,
+ drop_empty +
+ " CREATE DATABASE %s; ALTER DATABASE %s SET QUERY_STORE = ON "
+ "(OPERATION_MODE = READ_WRITE);" % (empty_db, empty_db))
+ try:
+ sql = ("SET NOCOUNT ON; EXECUTE dbo.sp_QuickieStore "
+ "@database_name = '%s', @find_parameter_sensitive = 1, "
+ "@debug = 1;" % empty_db)
+ out, err = _sqlcmd(server, password, sql)
+ R.check("ParamSensitive",
+ "@debug = 1 on an empty Query Store: no severe SQL error",
+ not find_sql_errors(out + "\n" + err),
+ str(find_sql_errors(out + "\n" + err)))
+ R.check("ParamSensitive", "@debug = 1 reaches the mode's DEBUG dumps",
+ "#ps_shape_stats is empty" in out,
+ "new dump section not reached")
+ finally:
+ _sqlcmd(server, password, drop_empty)
+
+
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--server", default="SQL2022")
@@ -403,6 +548,7 @@ def main():
mode_matrix(args.server, args.password, R)
filter_matrix(args.server, args.password, R)
bidirectional_tests(args.server, args.password, R)
+ parameter_sensitive_tests(args.server, args.password, R)
finally:
out, err = _sqlcmd(args.server, args.password, CLEANUP_SQL)
R.check("Fixture", "scratch database dropped",
From 2fb069ec0c7701de60e44f0f74abc6efb323c3c8 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:12:56 -0400
Subject: [PATCH 52/60] sp_LogHunter: August release audit fixes
Help no longer pretends @language_id translates anything: the
description and valid inputs now match the runtime warning that the
search strings are English literals. #errors gains an error_message
column populated from ERROR_MESSAGE() in the CATCH, so failed searches
explain themselves instead of reading like a clean bill of health. The
custom-message quoting comment now states the real remaining ceiling
(the nvarchar(4000) command column). Six comments rewritten to describe
the code as it is instead of narrating the previous revision.
Verified on SQL Server 2022: default run, help output, apostrophe
custom message, and an induced over-length failure that lands in
#errors with its error message attached.
Co-Authored-By: Claude Fable 5
---
sp_LogHunter/sp_LogHunter.sql | 62 +++++++++++++++++++----------------
1 file changed, 33 insertions(+), 29 deletions(-)
diff --git a/sp_LogHunter/sp_LogHunter.sql b/sp_LogHunter/sp_LogHunter.sql
index b2eb284c..1a5439a7 100644
--- a/sp_LogHunter/sp_LogHunter.sql
+++ b/sp_LogHunter/sp_LogHunter.sql
@@ -97,7 +97,7 @@ BEGIN
WHEN N'@custom_message' THEN 'if you want to search for a custom string'
WHEN N'@custom_message_only' THEN 'only search for the custom string'
WHEN N'@first_log_only' THEN 'only search through the first error log'
- WHEN N'@language_id' THEN 'to use something other than English'
+ WHEN N'@language_id' THEN 'does not translate the search strings; they are English literals. Use @custom_message on non-English servers'
WHEN N'@help' THEN 'how you got here'
WHEN N'@debug' THEN 'dumps raw temp table contents'
WHEN N'@version' THEN 'OUTPUT; for support'
@@ -111,7 +111,7 @@ BEGIN
WHEN N'@custom_message' THEN 'something specific you want to search for. no wildcards or substitutions.'
WHEN N'@custom_message_only' THEN 'NULL, 0, 1'
WHEN N'@first_log_only' THEN 'NULL, 0, 1'
- WHEN N'@language_id' THEN 'SELECT DISTINCT m.language_id FROM sys.messages AS m ORDER BY m.language_id;'
+ WHEN N'@language_id' THEN '1033; anything else only triggers a warning, because the search strings stay English'
WHEN N'@help' THEN 'NULL, 0, 1'
WHEN N'@debug' THEN 'NULL, 0, 1'
WHEN N'@version' THEN 'OUTPUT; for support'
@@ -306,12 +306,12 @@ BEGIN
Retire @days_back once we are in date-range mode, and do it HERE —
after the two fixups above, not before them.
- The check used to require BOTH dates to be present, but a caller who
- supplies only one gets the other filled in just above. Running the
- check first meant @days_back survived at its -7 default, and the log
- file pruner below then deleted every archive older than a week — so
- searching for an incident from three months ago quietly threw away
- the only file that contained it and returned nothing.
+ One supplied date is enough to be in date-range mode, because a
+ caller who supplies only one gets the other filled in just above.
+ If @days_back survived at its -7 default past this point, the log
+ file pruner below would delete every archive older than a week — so
+ searching for an incident from three months ago would quietly throw
+ away the only file that contained it and return nothing.
*/
IF @days_back IS NOT NULL
AND
@@ -406,9 +406,10 @@ BEGIN
start_date nvarchar(30) NULL,
end_date nvarchar(30) NULL,
/*
- Widened from 10: the value is now N'20260720', which is 11 characters
- rather than 10. At the old width it truncated to N'20260720 - an
- unterminated literal that broke every generated command.
+ nvarchar(12), not 10: the stored value is N'20260720' - eleven
+ characters counting the N prefix and quotes. Anything narrower
+ truncates it into an unterminated literal that breaks every
+ generated command.
*/
[current_date] nvarchar(12)
DEFAULT N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, 1, SYSDATETIME()), 112) + N'''',
@@ -445,7 +446,8 @@ BEGIN
id integer
PRIMARY KEY CLUSTERED
IDENTITY,
- command nvarchar(4000) NOT NULL
+ command nvarchar(4000) NOT NULL,
+ error_message nvarchar(4000) NULL
);
/*get all the error logs*/
@@ -472,9 +474,7 @@ BEGIN
final entry lands somewhere in the following 59 seconds - so the
reported stamp always understates when the file actually ends.
Comparing it exactly prunes archives that really do reach into
- the window. (HEAD hid this by typing the column as `date`, which
- floored everything to midnight and bought a full day of slack by
- accident.) Since enum <= true_end < enum + 60s, one minute is
+ the window. Since enum <= true_end < enum + 60s, one minute is
exactly enough.
*/
WHERE DATEADD(MINUTE, 1, e.log_date) < DATEADD(DAY, @days_back, SYSDATETIME())
@@ -488,10 +488,10 @@ BEGIN
Only the START of the window can rule a log file out, and that is the
whole subtlety here. sp_enumerrorlogs reports each archive's LAST
write, not its first, so a file stamped "March 10" holds entries
- reaching back to whenever the previous archive ended. The old
- predicate also deleted anything stamped AFTER @end_date — which threw
- away precisely the file that straddles the end of the window, i.e.
- the one most likely to contain the incident being investigated.
+ reaching back to whenever the previous archive ended. Excluding
+ anything stamped AFTER @end_date would throw away precisely the file
+ that straddles the end of the window, i.e. the one most likely to
+ contain the incident being investigated.
A file whose last write predates the window truly cannot contain it,
so that is the only safe exclusion. Keeping the newer files costs a
@@ -537,7 +537,7 @@ BEGIN
/*
Maybe you only want the first one anyway. Archive 0 IS the current
- log, so keeping "archive > 1" kept two files, not one.
+ log, so "archive > 0" is the predicate that keeps exactly one file.
*/
IF @first_log_only = 1
BEGIN
@@ -579,10 +579,9 @@ BEGIN
Canary floor is normally "at least 90 days back" so these
server-identity strings are found regardless of how recent
the caller is interested in. When the caller supplied
- @start_date/@end_date, @days_back is NULL at this point —
- the previous CASE collapsed to NULL, produced a NULL
- days_back literal, and xp_readerrorlog received NULL as a
- date argument and errored. Fall back to @start_date in
+ @start_date/@end_date, @days_back is NULL at this point, and
+ a NULL days_back literal would reach xp_readerrorlog as a
+ NULL date argument and error. Fall back to @start_date in
date-range mode so the canary has a concrete floor.
*/
days_back =
@@ -676,9 +675,12 @@ BEGIN
avoid closing the argument early and producing a syntax
error when sys.sp_executesql parses the generated batch. A
literal " needs no handling at all now that it is not a
- delimiter, and the search string is no longer subject to the
+ delimiter, and the search string is not subject to the
128-character identifier limit that silently dropped long
- messages into #errors. */
+ messages into #errors. The nvarchar(4000) command column is
+ the remaining ceiling: a @custom_message within a few
+ characters of 4000 (one less per apostrophe) still
+ truncates the generated command and lands in #errors. */
N'N''' + REPLACE(@custom_message, N'''', N'''''') + N'''',
N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'''',
N'N''' + CONVERT(nvarchar(30), @start_date, 121) + N'''',
@@ -786,15 +788,17 @@ BEGIN
@c;
END TRY
BEGIN CATCH
- /*Insert any searches that throw an error here*/
+ /*Insert any searches that throw an error here, and why they failed*/
INSERT
#errors
(
- command
+ command,
+ error_message
)
VALUES
(
- @c
+ @c,
+ ERROR_MESSAGE()
);
END CATCH;
END;
From 4d058311d57f8276f8f04da4d3f15de5fc177b02 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:15:55 -0400
Subject: [PATCH 53/60] sp_HealthParser: August release audit fixes
An explicit NULL @log_table_name_prefix now defaults to HealthParser
instead of walking past the length guard into QUOTENAME(NULL), which
silently no-opped every logging table (verified: 14 tables created
where before there were zero). Help text documents the 111-character
prefix limit. Two query_text fallbacks switch ISNULL to COALESCE so
the fallback is not typed down to nvarchar(257) and truncated, with
comments explaining why COALESCE is load-bearing. The two logging
validation errors now say the procedure stops instead of claiming
logging will be disabled. Header gains the standard @help/@debug
blocks and the help intro now carries proper support URLs, matching
every sibling proc.
Co-Authored-By: Claude Fable 5
---
sp_HealthParser/sp_HealthParser.sql | 29 +++++++++++++++++++++++------
1 file changed, 23 insertions(+), 6 deletions(-)
diff --git a/sp_HealthParser/sp_HealthParser.sql b/sp_HealthParser/sp_HealthParser.sql
index b332c5f8..2a5f6063 100644
--- a/sp_HealthParser/sp_HealthParser.sql
+++ b/sp_HealthParser/sp_HealthParser.sql
@@ -28,6 +28,14 @@ GO
Copyright 2026 Darling Data, LLC
https://www.erikdarling.com/
+For usage and licensing details, run:
+EXECUTE sp_HealthParser
+ @help = 1;
+
+For working through errors:
+EXECUTE sp_HealthParser
+ @debug = 1;
+
For support, head over to GitHub:
https://code.erikdarling.com
*/
@@ -82,7 +90,8 @@ BEGIN
'hi, i''m sp_HealthParser!' UNION ALL
SELECT 'you can use me to examine the contents of the system_health extended event session' UNION ALL
SELECT 'i apologize if i take a long time, i have to do a lot of XML processing' UNION ALL
- SELECT 'from your loving sql server consultant, erik darling: erikdarling.com';
+ SELECT 'you got me from https://code.erikdarling.com' UNION ALL
+ SELECT 'from your loving sql server consultant, erik darling: https://erikdarling.com';
/*
Parameters
@@ -133,7 +142,7 @@ BEGIN
WHEN N'@log_to_table' THEN N'0 or 1'
WHEN N'@log_database_name' THEN N'any valid database name'
WHEN N'@log_schema_name' THEN N'any valid schema name'
- WHEN N'@log_table_name_prefix' THEN N'any valid identifier'
+ WHEN N'@log_table_name_prefix' THEN N'any valid identifier, 111 characters or fewer'
WHEN N'@log_retention_days' THEN N'a positive integer'
WHEN N'@version' THEN N'none'
WHEN N'@version_date' THEN N'none'
@@ -532,6 +541,8 @@ AND ca.utc_timestamp < @end_date';
@log_database_name = ISNULL(@log_database_name, DB_NAME()),
/* Default schema name to dbo if not specified */
@log_schema_name = ISNULL(@log_schema_name, N'dbo'),
+ /* An explicit NULL prefix walks past the length guard and QUOTENAME(NULL) silently no-ops every logging table */
+ @log_table_name_prefix = ISNULL(@log_table_name_prefix, N'HealthParser'),
@log_retention_days =
CASE
WHEN @log_retention_days < 0
@@ -548,7 +559,7 @@ AND ca.utc_timestamp < @end_date';
*/
IF LEN(@log_table_name_prefix) > 111
BEGIN
- RAISERROR('@log_table_name_prefix is limited to 111 characters, so the longest table name suffix (_SignificantWaits) still fits in an identifier. Logging will be disabled.', 11, 1) WITH NOWAIT;
+ RAISERROR('@log_table_name_prefix is limited to 111 characters, so the longest table name suffix (_SignificantWaits) still fits in an identifier. Shorten it and run me again.', 11, 1) WITH NOWAIT;
RETURN;
END;
@@ -561,7 +572,7 @@ AND ca.utc_timestamp < @end_date';
WHERE d.name = @log_database_name
)
BEGIN
- RAISERROR('The specified logging database %s does not exist. Logging will be disabled.', 11, 1, @log_database_name) WITH NOWAIT;
+ RAISERROR('The specified logging database %s does not exist. Fix @log_database_name and run me again.', 11, 1, @log_database_name) WITH NOWAIT;
RETURN;
END;
@@ -5400,9 +5411,12 @@ AND ca.utc_timestamp < @end_date';
rendering the query column blank. The
raw text still carries the database and
object ids, which beats an empty cell.
+ COALESCE, not ISNULL: ISNULL types the
+ result as its first argument and cuts
+ the fallback text to 257 characters.
*/
[processing-instruction(query)] =
- ISNULL
+ COALESCE
(
OBJECT_SCHEMA_NAME
(
@@ -6024,9 +6038,12 @@ AND ca.utc_timestamp < @end_date';
functions, and the concatenation
with them — falling back to the
raw text keeps the ids visible.
+ COALESCE, not ISNULL: ISNULL types
+ the result as its first argument and
+ cuts the fallback to 257 characters.
*/
[processing-instruction(query)] =
- ISNULL
+ COALESCE
(
OBJECT_SCHEMA_NAME
(
From 1f3a0c62665ba2c71c2bda2b67296ae633a0b47a Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:22:23 -0400
Subject: [PATCH 54/60] sp_PressureDetector: August release audit fixes
The CPU logging insert no longer double-logs every sample: both CPU
XML documents now stamp sample_time from a single frozen SYSDATETIME()
and ms_ticks reading, so the UNION actually collapses samples that
tripped both thresholds (verified: 256 logged rows with 256 distinct
sample times, where the same run previously wrote 512). The unused
dm_os_sys_info join drops out of both builders. Help documents the
112-character @log_table_name_prefix cap, three user-visible
utilization typos are fixed, and five comments are rewritten to state
their constraints instead of narrating the previous revision.
Co-Authored-By: Claude Fable 5
---
sp_PressureDetector/sp_PressureDetector.sql | 97 ++++++++++++---------
1 file changed, 54 insertions(+), 43 deletions(-)
diff --git a/sp_PressureDetector/sp_PressureDetector.sql b/sp_PressureDetector/sp_PressureDetector.sql
index 66237a49..d2d2d99e 100644
--- a/sp_PressureDetector/sp_PressureDetector.sql
+++ b/sp_PressureDetector/sp_PressureDetector.sql
@@ -53,7 +53,7 @@ ALTER PROCEDURE
@skip_queries bit = 0, /*if you want to skip looking at running queries*/
@skip_plan_xml bit = 0, /*if you want to skip getting plan XML*/
@minimum_disk_latency_ms smallint = 100, /*low bound for reporting disk latency*/
- @cpu_utilization_threshold smallint = 50, /*low bound for reporting high cpu utlization*/
+ @cpu_utilization_threshold smallint = 50, /*low bound for reporting high cpu utilization*/
@skip_waits bit = 0, /*skips waits when you do not need them on every run*/
@skip_perfmon bit = 0, /*skips perfmon counters when you do not need them on every run*/
@sample_seconds tinyint = 0, /*take a sample of your server's metrics*/
@@ -113,7 +113,7 @@ BEGIN
WHEN N'@skip_queries' THEN N'if you want to skip looking at running queries'
WHEN N'@skip_plan_xml' THEN N'if you want to skip getting plan XML'
WHEN N'@minimum_disk_latency_ms' THEN N'low bound for reporting disk latency'
- WHEN N'@cpu_utilization_threshold' THEN N'low bound for reporting high cpu utlization'
+ WHEN N'@cpu_utilization_threshold' THEN N'low bound for reporting high cpu utilization'
WHEN N'@skip_waits' THEN N'skips waits when you do not need them on every run'
WHEN N'@skip_perfmon' THEN N'skips perfmon counters when you do not need them on every run'
WHEN N'@sample_seconds' THEN N'take a sample of your server''s metrics'
@@ -135,14 +135,14 @@ BEGIN
WHEN N'@skip_queries' THEN N'0 or 1'
WHEN N'@skip_plan_xml' THEN N'0 or 1'
WHEN N'@minimum_disk_latency_ms' THEN N'a reasonable number of milliseconds for disk latency'
- WHEN N'@cpu_utilization_threshold' THEN N'a reasonable cpu utlization percentage'
+ WHEN N'@cpu_utilization_threshold' THEN N'a reasonable cpu utilization percentage'
WHEN N'@skip_waits' THEN N'0 or 1'
WHEN N'@skip_perfmon' THEN N'0 or 1'
WHEN N'@sample_seconds' THEN N'a valid tinyint: 0-255'
WHEN N'@log_to_table' THEN N'0 or 1'
WHEN N'@log_database_name' THEN N'any valid database name'
WHEN N'@log_schema_name' THEN N'any valid schema name'
- WHEN N'@log_table_name_prefix' THEN N'any valid identifier'
+ WHEN N'@log_table_name_prefix' THEN N'any valid identifier, 112 characters or fewer'
WHEN N'@log_retention_days' THEN N'a positive integer'
WHEN N'@help' THEN N'0 or 1'
WHEN N'@troubleshoot_blocking' THEN N'0 or 1'
@@ -508,14 +508,13 @@ OPTION(MAXDOP 1, RECOMPILE);',
/* Default schema name to dbo if not specified */
@log_schema_name = ISNULL(@log_schema_name, N'dbo'),
/*
- Default the prefix too. It was the one log identifier with no
- guard, so a NULL (an automation variable that resolved to
- NULL) made QUOTENAME(NULL + N'_Waits') NULL, which nulled
- every CREATE / INSERT / DELETE command string — and
- sp_executesql runs a NULL batch as a silent no-op. The proc
- returned success having created no tables and logged nothing,
- with every result set suppressed because it was in logging
- mode. Total silent data loss.
+ Default the prefix too: it is the one log identifier that
+ QUOTENAME touches unguarded, and a NULL prefix (say, an
+ automation variable that resolves to NULL) nulls every
+ CREATE / INSERT / DELETE command string, which sp_executesql
+ runs as a silent no-op. In logging mode every result set is
+ suppressed, so nothing would announce that no tables were
+ created and nothing was logged.
*/
@log_table_name_prefix = ISNULL(@log_table_name_prefix, N'PressureDetector');
@@ -2315,12 +2314,12 @@ OPTION(MAXDOP 1, RECOMPILE);',
/*
Filter on the raw counter, not the per-second STRING.
total_per_second is cntr_value / uptime-in-seconds in
- integer arithmetic, so every counter smaller than the
- server's uptime floored to '0' and got dropped here —
- and those are exactly the point-in-time pressure gauges
- (Memory Grants Pending, Processes Blocked, deadlocks,
- which sit at single digits). The load step already keeps
- only cntr_value > 0, so this just stops re-dropping them.
+ integer arithmetic, so any counter smaller than the
+ server's uptime floors to '0', and the counters that sit
+ at single digits (Memory Grants Pending, Processes
+ Blocked, deadlocks) are exactly the point-in-time
+ pressure gauges. The load step already keeps only
+ cntr_value > 0, so this filter matches it.
*/
WHERE p.cntr_value > 0
ORDER BY
@@ -3439,12 +3438,12 @@ OPTION(MAXDOP 1, RECOMPILE);',
ELSE N''
END
/*
- Without this ELSE, @skip_plan_xml = 1 made the outer
- CASE return NULL, CONVERT(nvarchar(max), NULL) is NULL,
- and the whole @mem_sql string collapsed to NULL — so
- skipping the plan XML silently discarded the entire
- "queries with memory grants" result set. The CPU
- builder has this ELSE; this one was missing it.
+ This ELSE is load-bearing: without it, @skip_plan_xml
+ = 1 makes the outer CASE return NULL, CONVERT of NULL
+ is NULL, and the whole @mem_sql string collapses to
+ NULL, silently discarding the entire "queries with
+ memory grants" result set. The CPU builder carries the
+ same ELSE for the same reason.
*/
ELSE N''
END +
@@ -3537,10 +3536,10 @@ OPTION(MAXDOP 1, RECOMPILE);',
/*
TRY/CATCH so a failure fetching query plans does not take the
whole report down with it. This batch SETs LOCK_TIMEOUT to
- fail fast, but with XACT_ABORT ON and no handler a lock
- timeout (error 1222 — plausible under the very plan-cache
- churn being diagnosed) aborted the entire procedure, losing
- every section after this one. Now it degrades to a warning.
+ fail fast, and with XACT_ABORT ON an unhandled lock timeout
+ (error 1222, plausible under the very plan-cache churn being
+ diagnosed) would abort the entire procedure, losing every
+ section after this one. Degrade to a warning instead.
*/
BEGIN TRY
EXECUTE sys.sp_executesql
@@ -3717,8 +3716,21 @@ OPTION(MAXDOP 1, RECOMPILE);',
@cpu_details_output OUTPUT;
/*
- Checking for high CPU utilization periods
+ Checking for high CPU utilization periods.
+ Both CPU documents below must stamp identical sample_times for
+ the UNION in the logging insert to collapse samples that appear
+ in each, so the two moving inputs are frozen once here instead
+ of each query reading SYSDATETIME() and ms_ticks at its own
+ moment and stamping the same ring buffer record differently
*/
+ DECLARE
+ @utilization_now datetime2(7) = SYSDATETIME(),
+ @utilization_ms_ticks bigint;
+
+ SELECT
+ @utilization_ms_ticks = osi.ms_ticks
+ FROM sys.dm_os_sys_info AS osi;
+
SELECT
@cpu_utilization =
x.cpu_utilization
@@ -3732,8 +3744,8 @@ OPTION(MAXDOP 1, RECOMPILE);',
DATEADD
(
SECOND,
- (t.timestamp - osi.ms_ticks) / 1000,
- SYSDATETIME()
+ (t.timestamp - @utilization_ms_ticks) / 1000,
+ @utilization_now
)
),
sqlserver_cpu_utilization =
@@ -3743,8 +3755,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
- t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]','integer')),
total_cpu_utilization =
(100 - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'integer'))
- FROM sys.dm_os_sys_info AS osi
- CROSS JOIN
+ FROM
(
SELECT
dorb.timestamp,
@@ -3794,8 +3805,8 @@ OPTION(MAXDOP 1, RECOMPILE);',
DATEADD
(
SECOND,
- (t.timestamp - osi.ms_ticks) / 1000,
- SYSDATETIME()
+ (t.timestamp - @utilization_ms_ticks) / 1000,
+ @utilization_now
)
),
sqlserver_cpu_utilization =
@@ -3805,8 +3816,7 @@ OPTION(MAXDOP 1, RECOMPILE);',
- t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]','integer')),
total_cpu_utilization =
(100 - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'integer'))
- FROM sys.dm_os_sys_info AS osi
- CROSS JOIN
+ FROM
(
SELECT
dorb.timestamp,
@@ -3876,12 +3886,13 @@ OPTION(MAXDOP 1, RECOMPILE);',
UNION both CPU signals into the same insert. The event table
already carries other_process_cpu_utilization and
total_cpu_utilization, but the SQL-CPU XML only holds samples
- that tripped SQL's own threshold — so a sample where an
- external process pegged the box while SQL sat idle was never
- logged. Adding the external XML captures exactly those; UNION
- (not UNION ALL) collapses samples that tripped both. The
- no-data placeholder XML has no sample_time node, so its
- exist() filter is false and it contributes nothing.
+ that tripped SQL's own threshold, so a sample where an
+ external process pegs the box while SQL sits idle appears
+ only in the external XML. Both documents stamp identical
+ sample_times from the inputs frozen above, which is what
+ lets UNION (not UNION ALL) collapse samples that tripped
+ both. The no-data placeholder XML has no sample_time node,
+ so its exist() filter is false and it contributes nothing.
*/
SET @insert_sql = N'
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
From e67ef492961ae0c21517d25e25d4587d891579d8 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:22:24 -0400
Subject: [PATCH 55/60] sp_QueryReproBuilder: August release audit fixes
The help section now prints the MIT license block like every sibling
proc, instead of promising licensing details and returning without
them, and the help footer points at the support URL rather than an
email address. The fill-in parameter splitter handles the comma-space
form hand-written sp_executesql calls store in Query Store, so a
second parameter no longer vanishes from the value list; the harness
assertion is tightened to the placeholder count that actually
discriminates. @end_date_original now gets the same seven days as
@end_date when the date correction fires, mirroring the sp_QuickieStore
fix. #parameter_shred joins the @debug dumps, and one newly written
nvarchar(MAX) drops to lowercase.
Harness: 239 passed, 0 failed on SQL Server 2022.
Co-Authored-By: Claude Fable 5
---
sp_QueryReproBuilder/sp_QueryReproBuilder.sql | 54 +++++++++++++++++--
sp_QueryReproBuilder/tests/run_tests.py | 6 ++-
2 files changed, 55 insertions(+), 5 deletions(-)
diff --git a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
index 5a77a50a..f95aebdd 100644
--- a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
+++ b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
@@ -105,7 +105,7 @@ BEGIN
SELECT 'i extract query text and parameters from query plans' UNION ALL
SELECT 'and set them up to run with sp_executesql' UNION ALL
SELECT '' UNION ALL
- SELECT 'from your loving sql server consultant, erik darling: erikdarling@hey.com';
+ SELECT 'from your loving sql server consultant, erik darling: https://erikdarling.com';
/*Parameters*/
SELECT
@@ -203,6 +203,38 @@ BEGIN
ap.parameter_id
OPTION(RECOMPILE);
+ /*
+ License to F5
+ */
+ SELECT
+ mit_license_yo =
+ 'i am MIT licensed, so like, do whatever'
+ UNION ALL
+
+ SELECT
+ mit_license_yo =
+ 'see printed messages for full license';
+
+ RAISERROR('
+MIT License
+
+Copyright 2026 Darling Data, LLC
+
+https://www.erikdarling.com/
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute,
+sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+', 0, 1) WITH NOWAIT;
+
RETURN;
END;
@@ -530,11 +562,16 @@ BEGIN
7,
@start_date
),
+ /*
+ Same seven days as @end_date above, mirroring sp_QuickieStore:
+ a different span here would record a window the queries did not
+ actually use
+ */
@end_date_original =
DATEADD
(
DAY,
- 1,
+ 7,
@start_date_original
);
END;
@@ -4195,7 +4232,7 @@ SELECT
param_data_type =
cr.c.value(N'@ParameterDataType', N'sysname'),
param_compiled_value =
- cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)')
+ cr.c.value(N'@ParameterCompiledValue', N'nvarchar(max)')
FROM #query_store_plan AS qsp
CROSS APPLY
(
@@ -4374,7 +4411,7 @@ CROSS APPLY
query with a scaled-type parameter.
*/
N'' +
- REPLACE(prefix.param_prefix, N',@', N'
@') +
+ REPLACE(REPLACE(prefix.param_prefix, N', @', N',@'), N',@', N'
@') +
N'
' AS xml
)
) AS px
@@ -5204,6 +5241,15 @@ BEGIN
qsrs.plan_id
OPTION(RECOMPILE);
+ SELECT
+ table_name =
+ N'#parameter_shred',
+ ps.*
+ FROM #parameter_shred AS ps
+ ORDER BY
+ ps.plan_id
+ OPTION(RECOMPILE);
+
SELECT
table_name =
N'#query_store_plan',
diff --git a/sp_QueryReproBuilder/tests/run_tests.py b/sp_QueryReproBuilder/tests/run_tests.py
index 1b408a38..36b66c83 100644
--- a/sp_QueryReproBuilder/tests/run_tests.py
+++ b/sp_QueryReproBuilder/tests/run_tests.py
@@ -521,10 +521,14 @@ def add(name, plan, checks, echo=False, note=""):
# Same, with a scaled type in the text: the declared numeric(10,2) must not
# be split on its internal comma while being carried into the declaration.
+ # The "?, ?" check is the one that can actually fail: with the comma-space
+ # form unsplit, @b vanishes from the value list and only one ? is emitted.
+ # The declaration side rebuilds as "numeric(10,2), @b int" either way, so a
+ # substring check on it alone cannot distinguish merged from split.
add("qmark:scaled_fillin",
make_plan("(@a numeric(10,2), @b int)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac "
"WHERE ac.column_id = @b", params=None),
- {"expect_qmark": True, "decls_contain": ["numeric(10,2)"]})
+ {"expect_qmark": True, "decls_contain": ["numeric(10,2)", "@b int", "?, ?"]})
# ---------- First-ParameterList-wins hazard ----------------------------
# E6-style control: real ParameterList first, decoy second -> binds 555.
From 840a61d76d63d596436a215dbd1167b1a49e83af Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:24:23 -0400
Subject: [PATCH 56/60] sp_QuickieStore: August release audit fixes
@end_date_original now gets the same seven days as @end_date when the
date-order correction fires, so the logging tables and debug output
record the window the queries actually used instead of understating it
by six days. Help gains the ranked_on column in the shape volatility
summary guide, matches the timed out or cancelled signal text the code
emits, and both takeover modes now say exactly which parameters they
honor so silently ignored filters stop reading as filtered results.
Matrix: 157 passed, 0 failed on SQL Server 2022.
Co-Authored-By: Claude Fable 5
---
sp_QuickieStore/sp_QuickieStore.sql | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/sp_QuickieStore/sp_QuickieStore.sql b/sp_QuickieStore/sp_QuickieStore.sql
index 124c65c2..f8fcf1b1 100644
--- a/sp_QuickieStore/sp_QuickieStore.sql
+++ b/sp_QuickieStore/sp_QuickieStore.sql
@@ -419,6 +419,9 @@ BEGIN
high_impact_columns =
'when using @find_high_impact = 1, the result set contains these columns:' UNION ALL
SELECT REPLICATE('-', 100) UNION ALL
+ SELECT 'this mode honors @top, @sort_order (as a tiebreak), @work_start/@work_end, and @primary_window; the other' UNION ALL
+ SELECT ' filter parameters (@procedure_name, @query_text_search, include/ignore lists, etc.) do not apply here.' UNION ALL
+ SELECT REPLICATE('-', 100) UNION ALL
SELECT 'database_name: the database being analyzed' UNION ALL
SELECT 'start_date, end_date: the time window analyzed (UTC)' UNION ALL
SELECT REPLICATE('-', 100) UNION ALL
@@ -487,6 +490,8 @@ BEGIN
SELECT ' so recompiles of the same plan are analyzed together. Wild metric swings within one shape mean' UNION ALL
SELECT ' the same plan is fast for some parameter values and slow for others: classic parameter sensitivity.' UNION ALL
SELECT ' Only regular executions feed the statistics; aborted and exception executions are counted separately.' UNION ALL
+ SELECT ' This mode honors @top, @sort_order, @execution_count, and @duration_ms; the other filter' UNION ALL
+ SELECT ' parameters (@procedure_name, @query_text_search, include/ignore lists, etc.) do not apply here.' UNION ALL
SELECT REPLICATE('-', 100) UNION ALL
SELECT 'database_name: the database being analyzed' UNION ALL
SELECT 'start_date, end_date: the time window analyzed' UNION ALL
@@ -514,7 +519,7 @@ BEGIN
SELECT ' intermittent waits (duration Nx, cpu Nx) - duration swings without cpu swings: blocking, grants, or I/O,' UNION ALL
SELECT ' not parameter sensitivity. Check top_waits.' UNION ALL
SELECT ' memory grant swings (Nx) / tempdb swings (Nx) - grant or spill behavior varies between executions.' UNION ALL
- SELECT ' timed out N times / errored N times - aborted (client timeout/cancel) and exception executions.' UNION ALL
+ SELECT ' timed out or cancelled N times / errored N times - aborted (client timeout/cancel) and exception executions.' UNION ALL
SELECT ' Timeouts on a volatile shape are often the bad-parameter executions themselves.' UNION ALL
SELECT ' psp variants involved (2022+) - Parameter Sensitive Plan optimization already dispatched variants here.' UNION ALL
SELECT ' other plan shapes exist (N, avg cpu X to Y ms) - the same query_hash compiled to other shapes whose' UNION ALL
@@ -534,7 +539,8 @@ BEGIN
SELECT 'total_shapes: how many plan shapes had regular executions in the time window' UNION ALL
SELECT 'shapes_past_floors: how many cleared the noise floors (executions and minimum cpu/duration)' UNION ALL
SELECT 'surfaced_shapes: how many made it into the detail result set after top-N ranking' UNION ALL
- SELECT 'multi_shape_query_hashes: how many query_hashes compiled to more than one plan shape in the window';
+ SELECT 'multi_shape_query_hashes: how many query_hashes compiled to more than one plan shape in the window' UNION ALL
+ SELECT 'ranked_on: which metric''s volatility ranked the results, from @sort_order';
/*
Limitations
@@ -3875,11 +3881,16 @@ BEGIN
7,
@start_date
),
+ /*
+ Same seven days as @end_date above: these originals feed the
+ logging tables and debug output, so a different span here would
+ record a window the queries did not actually use
+ */
@end_date_original =
DATEADD
(
DAY,
- 1,
+ 7,
@start_date_original
);
END;
From 778567f9595c6a0af72365000a9398ee68a082d7 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:25:47 -0400
Subject: [PATCH 57/60] sp_HumanEventsBlockViewer: August release audit fixes
The two logging validation errors now say the procedure stops instead
of claiming logging will be disabled while RETURNing, and both become
proper Unicode literals. The blocked-process-threshold exemption
comment now explains the auto-promotion that makes its condition
correct, instead of describing a fallback path that does not exist.
Co-Authored-By: Claude Fable 5
---
sp_HumanEvents/sp_HumanEventsBlockViewer.sql | 17 +++++++++--------
1 file changed, 9 insertions(+), 8 deletions(-)
diff --git a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
index e7714218..29002c49 100644
--- a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
+++ b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
@@ -287,11 +287,12 @@ IF EXISTS
AND @session_name NOT LIKE N'system%health'
/*
Table-source mode reads blocked process report XML somebody already
-captured into a table — the server's CURRENT threshold setting has no
-bearing on that, and this gate used to refuse the whole run over it.
-The exemption mirrors the routing condition exactly (@target_table AND
-@target_column both supplied): a partial table-parameter call falls
-back to reading an XE session, where the gate still applies.
+captured into a table, so the server's CURRENT threshold setting has
+no bearing on it. The exemption checks @target_table AND @target_column
+because table-mode routing guarantees both by the time any work
+happens: supplying either one auto-promotes @target_type to table mode
+further down, and a call with only one of the two routes to table mode
+and fails its parameter validation before any reading starts.
*/
AND (@target_table IS NULL OR @target_column IS NULL)
BEGIN
@@ -768,7 +769,7 @@ BEGIN
/*
QUOTENAME returns NULL for any input over 128 characters, and a NULL
- table name turns every logging statement below into a silent no-op —
+ table name turns every logging statement below into a silent no-op:
no table, no rows, no error. The one suffix appended to the prefix,
_BlockedProcessReport, is 21 characters, so the prefix gets 107.
*/
@@ -776,7 +777,7 @@ BEGIN
count toward the 128-character identifier limit inside QUOTENAME. */
IF DATALENGTH(@log_table_name_prefix) > 214
BEGIN
- RAISERROR('@log_table_name_prefix is limited to 107 characters, so the table name suffix (_BlockedProcessReport) still fits in an identifier. Logging will be disabled.', 11, 1) WITH NOWAIT;
+ RAISERROR(N'@log_table_name_prefix is limited to 107 characters, so the table name suffix (_BlockedProcessReport) still fits in an identifier. Shorten it and run me again.', 11, 1) WITH NOWAIT;
RETURN;
END;
@@ -789,7 +790,7 @@ BEGIN
WHERE d.name = @log_database_name
)
BEGIN
- RAISERROR('The specified logging database %s does not exist. Logging will be disabled.', 11, 1, @log_database_name) WITH NOWAIT;
+ RAISERROR(N'The specified logging database %s does not exist. Fix @log_database_name and run me again.', 11, 1, @log_database_name) WITH NOWAIT;
RETURN;
END;
From c9ba0eb3fff306e89f717e24f1849618e7b3bfc3 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:35:52 -0400
Subject: [PATCH 58/60] sp_HumanEvents: August release audit fixes
Startup session cleanup anchors its LIKE with an escaped underscore so
a user session named HumanEventsAudit is no longer dropped on every
run; keeper sessions are deliberately still exempt at startup, since
dropping them would break @keep_alive. The three logged-path waits
views no longer truncate avg_ms_per_wait with bigint integer division:
each hex-encoded view body is re-encoded with the same decimal(38,2)
conversion the interactive path uses, verified by decoding the new
blobs, byte-comparing everything but the fixed line, and reading the
resulting column metadata back off created views. The shutdown CATCH
guards its stop and drop statements so under XACT_ABORT ON the
original error reaches THROW instead of being lost entirely. Also:
@keep_prepare_rpc documents that results require lowering
@query_duration_ms and lists the full RPC filter, megabyte columns get
their _mb suffixes, the parameterization view UPDATE excludes its own
output row, nine keeper LIKE patterns gain underscore escapes, and the
executions aggregate returns bigint to match the logged views.
Harness: 195 passed, 0 failed on SQL Server 2022.
Co-Authored-By: Claude Fable 5
---
sp_HumanEvents/sp_HumanEvents.sql | 108 +++++++++++++++++++++---------
1 file changed, 78 insertions(+), 30 deletions(-)
diff --git a/sp_HumanEvents/sp_HumanEvents.sql b/sp_HumanEvents/sp_HumanEvents.sql
index 4ab2dd8b..8a9b12c4 100644
--- a/sp_HumanEvents/sp_HumanEvents.sql
+++ b/sp_HumanEvents/sp_HumanEvents.sql
@@ -53,7 +53,7 @@ ALTER PROCEDURE
@query_duration_ms decimal(18,3) = 500,
@query_sort_order nvarchar(20) = N'cpu',
@skip_plans bit = 0,
- @keep_prepare_rpc bit = 0, /*adds a result set of prepared-statement RPC calls (sp_prepare/sp_prepexec/sp_execute) the query_hash filter hides; helps diagnose driver batching like pyodbc fast_executemany*/
+ @keep_prepare_rpc bit = 0, /*adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare, sp_cursor, sp_describe_undeclared_parameters) the query_hash filter hides; helps diagnose driver batching like pyodbc fast_executemany. these RPCs run sub-millisecond, so seeing anything requires lowering @query_duration_ms to 0 or a fractional value; at the default of 500 the session filter drops them all*/
@blocking_duration_ms integer = 500,
@wait_type nvarchar(4000) = N'ALL',
@wait_duration_ms integer = 10,
@@ -142,7 +142,7 @@ BEGIN
WHEN N'@query_duration_ms' THEN N'(>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration'
WHEN N'@query_sort_order' THEN 'when you use the "query" event, lets you choose which metrics to sort results by'
WHEN N'@skip_plans' THEN 'when you use the "query" event, lets you skip collecting actual execution plans'
- WHEN N'@keep_prepare_rpc' THEN N'when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare) that are normally filtered out; the only reliable way to tell whether a client driver is preparing and reusing statements, e.g. pyodbc''s fast_executemany'
+ WHEN N'@keep_prepare_rpc' THEN N'when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare, anything matching sp_cursor%, and sp_describe_undeclared_parameters) that are normally filtered out; the only reliable way to tell whether a client driver is preparing and reusing statements, e.g. pyodbc''s fast_executemany. these calls almost always finish in well under a millisecond, so you have to lower @query_duration_ms (to 0, or a fractional value like 0.1) to get any rows back; at the default of 500 the event session filters them out before they are ever collected'
WHEN N'@blocking_duration_ms' THEN N'(>=) used to set a minimum blocking duration to collect data for'
WHEN N'@wait_type' THEN N'(inclusive) filter to only specific wait types'
WHEN N'@wait_duration_ms' THEN N'(>=) used to set a minimum time per wait to collect data for'
@@ -578,7 +578,20 @@ IF NOT EXISTS
RETURN;
END;
-/*clean up any old/dormant sessions*/
+/*
+Clean up any old/dormant sessions.
+
+The pattern is anchored to the prefix and the literal underscore is
+escaped with a bracket class. The old N'HumanEvents%' treated _ as a
+single-character wildcard and had nothing after the prefix, so a user
+session named HumanEventsAudit matched and got dropped on every run.
+
+Only the one-shot HumanEvents__ sessions belong here.
+keeper_HumanEvents_ sessions are deliberately left out: they exist to
+survive between runs when @keep_alive = 1, and the logging loop polls
+them. Dropping those is only correct under @cleanup = 1, which the
+cleanup label handles separately.
+*/
IF @azure = 0
BEGIN
INSERT
@@ -593,7 +606,7 @@ BEGIN
FROM sys.server_event_sessions AS ses
LEFT JOIN sys.dm_xe_sessions AS dxe
ON dxe.name = ses.name
- WHERE ses.name LIKE N'HumanEvents%'
+ WHERE ses.name LIKE N'HumanEvents[_]%'
AND (dxe.create_time < DATEADD(MINUTE, -1, SYSDATETIME())
OR dxe.create_time IS NULL);
END;
@@ -612,7 +625,7 @@ BEGIN
FROM sys.database_event_sessions AS ses
LEFT JOIN sys.dm_xe_database_sessions AS dxe
ON dxe.name = ses.name
- WHERE ses.name LIKE N'HumanEvents%'
+ WHERE ses.name LIKE N'HumanEvents[_]%'
AND (dxe.create_time < DATEADD(MINUTE, -1, SYSDATETIME())
OR dxe.create_time IS NULL);
END;
@@ -2142,16 +2155,25 @@ BEGIN
@skip_plans = 0, because both branches carry a plan_handle.
Written as SUM over 1s and 0s so no NULL ever reaches the
aggregate.
+
+ The CONVERT to bigint matches the shape the logged Queries
+ views use, SUM(CONVERT(bigint, ...)), and keeps the running
+ total off integer, which SUM over an integer literal would
+ overflow past 2.1 billion rows.
*/
executions =
SUM
(
- CASE
- WHEN qa.is_execution = 1
- AND qa.plan_handle IS NOT NULL
- THEN 1
- ELSE 0
- END
+ CONVERT
+ (
+ bigint,
+ CASE
+ WHEN qa.is_execution = 1
+ AND qa.plan_handle IS NOT NULL
+ THEN 1
+ ELSE 0
+ END
+ )
)
INTO #totals
FROM query_agg AS qa
@@ -2354,8 +2376,8 @@ BEGIN
sql_text = oa.c.value('(action[@name="sql_text"]/value/text())[1]', 'nvarchar(max)'),
duration_ms = oa.c.value('(data[@name="duration"]/value/text())[1]', 'bigint') / 1000.,
cpu_ms = oa.c.value('(data[@name="cpu_time"]/value/text())[1]', 'bigint') / 1000.,
- logical_reads = (oa.c.value('(data[@name="logical_reads"]/value/text())[1]', 'bigint') * 8) / 1024.,
- physical_reads = (oa.c.value('(data[@name="physical_reads"]/value/text())[1]', 'bigint') * 8) / 1024.,
+ logical_reads_mb = (oa.c.value('(data[@name="logical_reads"]/value/text())[1]', 'bigint') * 8) / 1024.,
+ physical_reads_mb = (oa.c.value('(data[@name="physical_reads"]/value/text())[1]', 'bigint') * 8) / 1024.,
writes_mb = (oa.c.value('(data[@name="writes"]/value/text())[1]', 'bigint') * 8) / 1024.,
row_count = oa.c.value('(data[@name="row_count"]/value/text())[1]', 'bigint')
FROM #human_events_xml AS xet
@@ -2399,8 +2421,8 @@ BEGIN
),
pr.duration_ms,
pr.cpu_ms,
- pr.logical_reads,
- pr.physical_reads,
+ pr.logical_reads_mb,
+ pr.physical_reads_mb,
pr.writes_mb,
pr.row_count
FROM prepare_rpc AS pr
@@ -2413,7 +2435,7 @@ BEGIN
OR pr.statement LIKE N'%sp[_]cursor%'
OR pr.statement LIKE N'%sp[_]describe[_]undeclared[_]parameters%'
)
- AND pr.statement NOT LIKE N'%sp[_]executesql%'
+ AND pr.statement NOT LIKE N'%sp[_]executesql%'
ORDER BY
pr.event_time
OPTION(RECOMPILE);
@@ -3795,7 +3817,7 @@ BEGIN
FROM sys.server_event_sessions AS ses
LEFT JOIN sys.dm_xe_sessions AS dxs
ON dxs.name = ses.name
- WHERE ses.name LIKE N'keeper_HumanEvents_%'
+ WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%'
AND dxs.create_time IS NOT NULL
)
BEGIN
@@ -3812,7 +3834,7 @@ BEGIN
FROM sys.server_event_sessions AS ses
LEFT JOIN sys.dm_xe_sessions AS dxs
ON dxs.name = ses.name
- WHERE ses.name LIKE N'keeper_HumanEvents_%'
+ WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%'
AND dxs.create_time IS NULL;
END;
ELSE
@@ -3825,7 +3847,7 @@ BEGIN
FROM sys.database_event_sessions AS ses
JOIN sys.dm_xe_database_sessions AS dxs
ON dxs.name = ses.name
- WHERE ses.name LIKE N'keeper_HumanEvents_%'
+ WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%'
)
BEGIN
IF @debug = 1 BEGIN RAISERROR(N'No matching active session names found starting with keeper_HumanEvents', 0, 1) WITH NOWAIT; END;
@@ -3841,7 +3863,7 @@ BEGIN
FROM sys.database_event_sessions AS ses
LEFT JOIN sys.dm_xe_database_sessions AS dxs
ON dxs.name = ses.name
- WHERE ses.name LIKE N'keeper_HumanEvents_%'
+ WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%'
AND dxs.create_time IS NULL;
END;
@@ -3893,7 +3915,7 @@ BEGIN
FROM sys.server_event_sessions AS s
JOIN sys.dm_xe_sessions AS r
ON r.name = s.name
- WHERE s.name LIKE N'keeper_HumanEvents_%';
+ WHERE s.name LIKE N'keeper[_]HumanEvents[_]%';
END;
ELSE
BEGIN
@@ -3923,7 +3945,7 @@ BEGIN
FROM sys.database_event_sessions AS s
JOIN sys.dm_xe_database_sessions AS r
ON r.name = s.name
- WHERE s.name LIKE N'keeper_HumanEvents_%';
+ WHERE s.name LIKE N'keeper[_]HumanEvents[_]%';
END;
/*If we're getting compiles, and the parameterization event is available*/
@@ -3934,7 +3956,7 @@ BEGIN
SELECT
1/0
FROM #human_events_worker AS hew
- WHERE hew.event_type LIKE N'keeper_HumanEvents_compiles%'
+ WHERE hew.event_type LIKE N'keeper[_]HumanEvents[_]compiles%'
)
BEGIN
INSERT
@@ -3962,7 +3984,7 @@ BEGIN
hew.output_schema,
hew.output_table + N'_parameterization'
FROM #human_events_worker AS hew
- WHERE hew.event_type LIKE N'keeper_HumanEvents_compiles%';
+ WHERE hew.event_type LIKE N'keeper[_]HumanEvents[_]compiles%';
END;
/*Update this column for when we see if we need to create views.*/
@@ -4217,11 +4239,11 @@ BEGIN
SELECT N'HumanEvents_RecompilesByQuery', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005200650063006F006D00700069006C006500730042007900510075006500720079000D000A00410053000D000A0057004900540048000D000A0020002000200020006300620071002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A0020002000200020002000200020002000730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D002C000D000A002000200020002000200020002000200074006F00740061006C005F007200650063006F006D00700069006C006500730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A002000200020002000200020002000200074006F00740061006C005F0063006F006D00700069006C0065005F0063007000750020003D002000530055004D00280063006F006D00700069006C0065005F006300700075005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F0063006F006D00700069006C0065005F0063007000750020003D002000410056004700280063006F006D00700069006C0065005F006300700075005F006D00730029002C000D000A00200020002000200020002000200020006D00610078005F0063006F006D00700069006C0065005F0063007000750020003D0020004D0041005800280063006F006D00700069006C0065005F006300700075005F006D00730029002C000D000A002000200020002000200020002000200074006F00740061006C005F0063006F006D00700069006C0065005F006400750072006100740069006F006E0020003D002000530055004D00280063006F006D00700069006C0065005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F0063006F006D00700069006C0065005F006400750072006100740069006F006E0020003D002000410056004700280063006F006D00700069006C0065005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006D00610078005F0063006F006D00700069006C0065005F006400750072006100740069006F006E0020003D0020004D0041005800280063006F006D00700069006C0065005F006400750072006100740069006F006E005F006D00730029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D000D000A002000200020002000470052004F005500500020004200590020000D000A0020002000200020002000200020002000730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D000D000A00200020002000200048004100560049004E00470020000D000A002000200020002000200020002000200043004F0055004E0054005F0042004900470028002A00290020003E003D002000310030000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A0020002000200020006B002E006F0062006A006500630074005F006E0061006D0065002C000D000A0020002000200020006B002E00730074006100740065006D0065006E0074005F0074006500780074002C000D000A00200020002000200063002E0074006F00740061006C005F007200650063006F006D00700069006C00650073002C000D000A00200020002000200063002E0074006F00740061006C005F0063006F006D00700069006C0065005F006300700075002C000D000A00200020002000200063002E006100760067005F0063006F006D00700069006C0065005F006300700075002C000D000A00200020002000200063002E006D00610078005F0063006F006D00700069006C0065005F006300700075002C000D000A00200020002000200063002E0074006F00740061006C005F0063006F006D00700069006C0065005F006400750072006100740069006F006E002C000D000A00200020002000200063002E006100760067005F0063006F006D00700069006C0065005F006400750072006100740069006F006E002C000D000A00200020002000200063002E006D00610078005F0063006F006D00700069006C0065005F006400750072006100740069006F006E000D000A00460052004F004D002000630062007100200041005300200063000D000A00430052004F005300530020004100500050004C0059000D000A0028000D000A002000200020002000530045004C00450043005400200054004F00500020002800310029000D000A00200020002000200020002000200020006B002E002A000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D0020004100530020006B000D000A00200020002000200057004800450052004500200063002E00730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D0020003D0020006B002E00730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D000D000A0020002000200020004F00520044004500520020004200590020006B002E0069006400200044004500530043000D000A00290020004100530020006B000D000A004F005200440045005200200042005900200063002E0074006F00740061006C005F007200650063006F006D00700069006C0065007300200044004500530043003B000D000A00
WHERE @compile_events = 1;
INSERT #view_check (view_name, view_definition)
- SELECT N'HumanEvents_WaitsByDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730042007900440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A002000200020002000770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730020006200790020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002F00200043004F0055004E0054005F0042004900470028002A0029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000770061006900740073005F007000650072005F007300650063006F006E00640020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028005300450043004F004E0044002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0068006F007500720020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C0049004600280044004100540045004400490046004600280048004F00550052002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0064006100790020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028004400410059002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029000D000A00460052004F004D002000770061006900740073000D000A00470052004F00550050002000420059000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074000D000A004F00520044004500520020004200590020000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00;
+ SELECT N'HumanEvents_WaitsByDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730042007900440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A002000200020002000770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730020006200790020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D00200043004F004E005600450052005400280064006500630069006D0061006C002800330038002C00320029002C002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002A00200031002E00300020002F00200043004F0055004E0054005F0042004900470028002A00290029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000770061006900740073005F007000650072005F007300650063006F006E00640020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028005300450043004F004E0044002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0068006F007500720020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C0049004600280044004100540045004400490046004600280048004F00550052002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0064006100790020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028004400410059002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029000D000A00460052004F004D002000770061006900740073000D000A00470052004F00550050002000420059000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074000D000A004F00520044004500520020004200590020000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00;
INSERT #view_check (view_name, view_definition)
- SELECT N'HumanEvents_WaitsByQueryAndDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F0057006100690074007300420079005100750065007200790041006E006400440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A00200020002000200070006C0061006E005F00770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270077006100690074007300200062007900200071007500650072007900200061006E00640020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002F00200043004F0055004E0054005F0042004900470028002A0029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000700077002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000700077002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000700077002E0077006100690074005F0074007900700065002C000D000A002000200020002000700077002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000730074002E0074006500780074002C000D000A002000200020002000710070002E00710075006500720079005F0070006C0061006E000D000A00460052004F004D00200070006C0061006E005F00770061006900740073002000410053002000700077000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00710075006500720079005F0070006C0061006E002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000710070000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00730071006C005F0074006500780074002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000730074000D000A004F00520044004500520020004200590020000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00;
+ SELECT N'HumanEvents_WaitsByQueryAndDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F0057006100690074007300420079005100750065007200790041006E006400440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A00200020002000200070006C0061006E005F00770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270077006100690074007300200062007900200071007500650072007900200061006E00640020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D00200043004F004E005600450052005400280064006500630069006D0061006C002800330038002C00320029002C002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002A00200031002E00300020002F00200043004F0055004E0054005F0042004900470028002A00290029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000700077002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000700077002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000700077002E0077006100690074005F0074007900700065002C000D000A002000200020002000700077002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000730074002E0074006500780074002C000D000A002000200020002000710070002E00710075006500720079005F0070006C0061006E000D000A00460052004F004D00200070006C0061006E005F00770061006900740073002000410053002000700077000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00710075006500720079005F0070006C0061006E002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000710070000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00730071006C005F0074006500780074002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000730074000D000A004F00520044004500520020004200590020000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00;
INSERT #view_check (view_name, view_definition)
- SELECT N'HumanEvents_WaitsTotal', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730054006F00740061006C000D000A00410053000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A00200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730027002C000D000A0020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A00200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002F00200043004F0055004E0054005F0042004900470028002A0029000D000A00460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A00470052004F005500500020004200590020000D000A002000200020002000770061002E0077006100690074005F0074007900700065000D000A004F00520044004500520020004200590020000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00;
+ SELECT N'HumanEvents_WaitsTotal', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730054006F00740061006C000D000A00410053000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A00200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730027002C000D000A0020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A00200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D00200043004F004E005600450052005400280064006500630069006D0061006C002800330038002C00320029002C002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002A00200031002E00300020002F00200043004F0055004E0054005F0042004900470028002A00290029000D000A00460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A00470052004F005500500020004200590020000D000A002000200020002000770061002E0077006100690074005F0074007900700065000D000A004F00520044004500520020004200590020000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00;
INSERT #view_check (view_name, view_definition)
SELECT N'HumanEvents_Compiles_Legacy', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F0043006F006D00700069006C00650073005F004C00650067006100630079000D000A00410053000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A0020002000200020006500760065006E0074005F00740069006D0065002C000D000A0020002000200020006500760065006E0074005F0074007900700065002C000D000A002000200020002000640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020006F0062006A006500630074005F006E0061006D0065002C000D000A002000200020002000730074006100740065006D0065006E0074005F0074006500780074000D000A00460052004F004D0020005B007200650070006C006100630065005F006D0065005D000D000A004F00520044004500520020004200590020000D000A0020002000200020006500760065006E0074005F00740069006D0065003B00
WHERE @compile_events = 0;
@@ -4247,6 +4269,14 @@ BEGIN
AND hew.is_table_created = 1
AND hew.is_view_created = 0;
+ /*
+ The compiles pattern matches two rows: the base compiles row and
+ the _parameterization row derived from it, whose output_table is
+ the base name plus that suffix. Without the closing predicate the
+ join picks either one non-deterministically, and the row it picks
+ decides whether the view points at the right table. Only the base
+ row is a valid source here.
+ */
UPDATE
vc
SET
@@ -4254,7 +4284,8 @@ BEGIN
FROM #view_check AS vc
JOIN #human_events_worker AS hew
ON vc.view_name = N'HumanEvents_Parameterization'
- AND hew.output_table LIKE N'keeper_HumanEvents_compiles%'
+ AND hew.output_table LIKE N'keeper[_]HumanEvents[_]compiles%'
+ AND hew.output_table NOT LIKE N'%[_]parameterization'
AND hew.is_table_created = 1
AND hew.is_view_created = 0;
@@ -5323,6 +5354,11 @@ BEGIN CATCH
session alone so the original error still surfaces via THROW.
Azure SQL DB uses database-scoped sessions, so the catalog
view differs; that's the only reason for the @azure branch.
+
+ The stop and the drop get the same treatment for the same
+ reason. Both are cleanup running inside the CATCH, so a
+ failure in either one is noise, and letting it escape would
+ replace the original error before THROW ever runs.
*/
BEGIN TRY
IF @azure = 0
@@ -5369,14 +5405,26 @@ BEGIN CATCH
RAISERROR(@stop_sql, 0, 1) WITH NOWAIT;
RAISERROR(N'all done, stopping session', 0, 1) WITH NOWAIT;
END;
- EXECUTE (@stop_sql);
+
+ BEGIN TRY
+ EXECUTE (@stop_sql);
+ END TRY
+ BEGIN CATCH
+ RAISERROR(N'Could not stop the event session while cleaning up. The original error follows.', 0, 1) WITH NOWAIT;
+ END CATCH;
IF @debug = 1
BEGIN
RAISERROR(@drop_sql, 0, 1) WITH NOWAIT;
RAISERROR(N'and dropping session', 0, 1) WITH NOWAIT;
END;
- EXECUTE (@drop_sql);
+
+ BEGIN TRY
+ EXECUTE (@drop_sql);
+ END TRY
+ BEGIN CATCH
+ RAISERROR(N'Could not drop the event session while cleaning up. The original error follows.', 0, 1) WITH NOWAIT;
+ END CATCH;
END;
END;
From b850061db5b6df7618d0a612e174a8efa5300312 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:36:56 -0400
Subject: [PATCH 59/60] sp_PerfCheck: August release audit fixes
The README is rebuilt against the code: all twelve parameters
documented (seven tuning thresholds were missing), four removed checks
deleted (1000, 4107, 7005, 7007), four undocumented checks added
(1002, 1012, 5000, 5004), and every stale threshold and priority
corrected, including the renamed cost-threshold and per-category wait
findings. The stray UTF-8 BOM is stripped. #results.object_name widens
to nvarchar(500) so a blob-storage file URL plus its type suffix
cannot abort the whole run with a truncation error. The three wait
threshold parameters widen to decimal(38, 2) to match the column they
compare against, since percent-of-uptime runs to thousands on many-core
servers. Check 9998 gets priority 50 so it renders as Informational
instead of Unknown. The tests README now describes the harness that
exists, including its CI wiring. New-code style is brought in line and
five past-tense comments now state their constraints in present tense.
Harness: 39 passed, 0 failed on SQL Server 2022.
Co-Authored-By: Claude Fable 5
---
sp_PerfCheck/README.md | 165 +++++++++++++++++++---------
sp_PerfCheck/sp_PerfCheck.sql | 200 +++++++++++++++++++++-------------
sp_PerfCheck/tests/README.md | 147 ++++++++++++++-----------
3 files changed, 322 insertions(+), 190 deletions(-)
diff --git a/sp_PerfCheck/README.md b/sp_PerfCheck/README.md
index 6e65c056..eda676f1 100644
--- a/sp_PerfCheck/README.md
+++ b/sp_PerfCheck/README.md
@@ -23,23 +23,42 @@
| Parameter | Data Type | Default | Description |
|-----------|-----------|---------|-------------|
| @database_name | sysname | NULL | Specific database to check; NULL checks all accessible user databases |
-| @help | bit | 0 | Displays this help information |
+| @slow_read_ms | decimal(10, 2) | 20.0 | Flag data-file reads slower than this many ms; High at 5x this value |
+| @slow_write_ms | decimal(10, 2) | 20.0 | Flag data-file writes slower than this many ms; High at 5x this value |
+| @significant_wait_threshold_pct | decimal(38, 2) | 10.0 | Minimum percent of uptime for a wait to be reported |
+| @wait_high_pct | decimal(38, 2) | 50.0 | A resource wait at or above this percent of uptime is High priority |
+| @wait_medium_pct | decimal(38, 2) | 20.0 | A resource wait at or above this percent of uptime is Medium priority |
+| @memory_grant_warning | integer | 100 | Forced memory grants at or above this cumulative count are Medium |
+| @memory_grant_critical | integer | 10000 | Forced memory grants at or above this cumulative count are High |
+| @help | bit | 0 | Displays help information |
| @debug | bit | 0 | Print diagnostic messages and intermediate query results |
| @version | varchar(30) | NULL OUTPUT | Returns version number |
| @version_date | datetime | NULL OUTPUT | Returns version date |
+The wait percentages are measured against server uptime and are deliberately wide
+(`decimal(38, 2)`): cumulative wait time across all schedulers routinely exceeds
+100% of wall-clock uptime on a many-core server.
+
+Any of the threshold parameters left NULL or set to a negative number falls back
+to its default, so you can override only the ones you care about.
+
## Usage
```sql
--- Basic check on all databases
-EXEC dbo.sp_PerfCheck;
+/* Basic check on all databases */
+EXECUTE dbo.sp_PerfCheck;
--- Check a specific database only
-EXEC dbo.sp_PerfCheck
+/* Check a specific database only */
+EXECUTE dbo.sp_PerfCheck
@database_name = 'YourDatabaseName';
--- Run with debug information
-EXEC dbo.sp_PerfCheck
+/* Loosen the storage thresholds for a busy, latency-tolerant system */
+EXECUTE dbo.sp_PerfCheck
+ @slow_read_ms = 50.0,
+ @slow_write_ms = 50.0;
+
+/* Run with debug information */
+EXECUTE dbo.sp_PerfCheck
@debug = 1;
```
@@ -49,8 +68,8 @@ All findings are assigned a priority level indicating severity and urgency:
| Priority | Label | Meaning |
|----------|-------|---------|
-| 10 | **Critical** | Server instability — crashes, offline resources, pending configuration changes |
-| 20 | **High** | Active performance degradation — severe I/O latency, memory pressure, high deadlock rates |
+| 10 | **Critical** | Server instability: crashes, offline resources, pending configuration changes |
+| 20 | **High** | Active performance degradation: severe I/O latency, memory pressure, high deadlock rates |
| 30 | **Medium** | Moderate impact or risky configuration that will likely cause problems |
| 40 | **Low** | Best practice recommendations that improve reliability |
| 50 | **Informational** | Awareness items and non-default settings that may be intentional |
@@ -63,17 +82,25 @@ Results include a `priority_label` column for readability and are sorted by prio
| Check | Finding | Priority | Description |
|-------|---------|----------|-------------|
-| 1000 | Non-Default Configuration | Informational (50) | Reports sp_configure options changed from default |
-| 1001 | Min Memory Too Close To Max | Low (40) | Min server memory >= 90% of max |
-| 1003 | MAXDOP Not Configured | Low (40) | Default MAXDOP (0) on multi-processor system |
-| 1004 | Low Cost Threshold | Low (40) | Cost threshold for parallelism <= 5 |
+| 1001 | Min Server Memory Too Close To Max | Low (40) | Min server memory >= 90% of max server memory |
+| 1002 | Max Server Memory Too Close To Physical Memory | High (20) | Max server memory >= 95% of physical memory, starving the OS |
+| 1003 | MAXDOP Not Configured | Low (40) | Default MAXDOP (0) on a server with more than 8 logical processors |
+| 1004 | Cost Threshold for Parallelism Too Low | High (20) / Low (40) | Fires below 50; High when still at or under the default of 5 |
| 1005 | Priority Boost Enabled | High (20) | Dangerous setting affecting Windows scheduling |
| 1006 | Lightweight Pooling Enabled | Low (40) | Fiber mode rarely beneficial |
-| 1007 | Config Pending Reconfigure | Critical (10) | Server not running intended configuration |
+| 1007 | Configuration Pending Reconfigure | Critical (10) | Server not running its intended configuration |
| 1008 | Affinity Mask Configured | Informational (50) | Manual CPU binding |
| 1009 | Affinity I/O Mask Configured | Informational (50) | Manual I/O CPU binding |
| 1010 | Affinity64 Mask Configured | Informational (50) | CPU binding for processors 33-64 |
| 1011 | Affinity64 I/O Mask Configured | Informational (50) | I/O binding for processors 33-64 |
+| 1012 | Notable Global Trace Flag | High (20) / Medium (30) / Low (40) | Interprets notable global trace flags; see below |
+
+Check 1012 reports on global trace flags 1211, 1224, 3608, 3609, 834, 4199, 8048,
+1117, 1118, and 2371, with an explanation of what each one does and whether it is
+still needed on modern versions. Flags 1211, 3608, and 3609 are High; 1224 and 834
+are Medium; the rest are Low. The complete list of enabled global trace flags is
+always reported in the server information result set, so benign flags are visible
+without being raised as findings.
### TempDB Configuration
@@ -82,50 +109,78 @@ Results include a `priority_label` column for readability and are sorted by prio
| 2001 | Single TempDB Data File | Medium (30) | Single file causes allocation contention |
| 2002 | Odd Number of TempDB Files | Informational (50) | File count not optimal for CPU count |
| 2003 | More TempDB Files Than CPUs | Informational (50) | More data files than logical processors |
-| 2004 | Uneven TempDB File Sizes | Low (40) | Data files vary in size by >10% |
-| 2005 | Mixed TempDB Autogrowth | Low (40) | Inconsistent growth settings across files |
-| 2006 | Percentage Growth in TempDB | Low (40) | Percentage-based growth in TempDB files |
-| 2010 | TempDB Allocation Contention | Medium (30) | Active pagelatch contention detected |
+| 2004 | Uneven TempDB Data File Sizes | Low (40) | Data files vary in size by more than 10% |
+| 2005 | Mixed TempDB Autogrowth Settings | Low (40) | Inconsistent growth settings across files |
+| 2006 | Percentage Auto-Growth Setting in TempDB | Low (40) | Percentage-based growth in TempDB files |
+| 2010 | TempDB Allocation Contention Detected | Medium (30) | Active pagelatch contention detected |
### Storage Performance
| Check | Finding | Priority | Description |
|-------|---------|----------|-------------|
-| 3001 | Slow Read Latency | High (20) / Medium (30) | >1000 ms = High, >500 ms = Medium per file |
-| 3002 | Slow Write Latency | High (20) / Medium (30) | >1000 ms = High, >500 ms = Medium per file |
-| 3003 | Multiple Slow Files on Storage Location | High (20) | Systemic storage problem on a drive |
+| 3001 | Slow Read Latency | High (20) / Medium (30) | Medium above @slow_read_ms (20 ms default), High above 5x that (100 ms default) |
+| 3002 | Slow Write Latency | High (20) / Medium (30) | Medium above @slow_write_ms (20 ms default), High above 5x that (100 ms default) |
+| 3003 | Multiple Slow Files on Storage Location | High (20) | More than one slow file sharing a drive or volume |
+
+Checks 3001 and 3002 only fire for files with more than 1,000 reads or writes
+respectively, so a barely-used file cannot trip them on a handful of slow samples.
### Server Health
| Check | Finding | Priority | Description |
|-------|---------|----------|-------------|
| 4001 | Offline CPU Schedulers | Critical (10) | CPUs offline, reducing processing power |
-| 4101 | Memory-Starved Queries (forced) | High (20) | Forced grants causing tempdb spills |
-| 4102 | Memory Dumps Detected | Critical (10) | Server crashing in last 90 days |
-| 4103 | Memory Grant Timeouts | High (20) | Queries can't get memory |
-| 4104 | Large Security Token Cache | High/Medium/Low | >5 GB=20, >2 GB=30, >1 GB=40 |
-| 4105 | Lock Pages Not Enabled | Low (40) | Best practice for >=32 GB RAM |
-| 4106 | IFI Disabled | Low (40) | Best practice for file operations |
-| 4107 | Resource Governor Enabled | Informational (50) | May be intentional |
+| 4101 | Memory-Starved Queries: Forced Grants | High (20) / Medium (30) / Low (40) | Graduated on @memory_grant_critical (10000) and @memory_grant_warning (100) |
+| 4102 | Memory Dumps Detected In Last 90 Days | Critical (10) | Server crashing in the last 90 days |
+| 4103 | Memory-Starved Queries: Grant Timeouts | High (20) / Medium (30) / Low (40) | More than 100 = High, more than 10 = Medium, otherwise Low |
+| 4104 | Large Security Token Cache | High (20) / Medium (30) / Low (40) | Over 5 GB = High, over 2 GB = Medium, fires at 1 GB |
+| 4105 | Lock Pages in Memory Not Enabled | Low (40) | Best practice for servers with 32 GB or more of RAM |
+| 4106 | Instant File Initialization Disabled | Low (40) | Best practice for file creation and growth operations |
+
+Checks 4105 and 4106 are on-premises only. Azure SQL Managed Instance and AWS RDS
+do not expose the underlying Windows user rights, so flagging them there would be
+unactionable.
### Trace Events
| Check | Finding | Priority | Description |
|-------|---------|----------|-------------|
-| 5001 | Slow Auto-Growth | Medium (30) / Low (40) | Log grows = Medium, data grows = Low |
-| 5002 | Auto-Shrink Events | Low (40) | Harmful config executing |
-| 5003 | Disruptive DBCC Commands | Medium (30) / Informational (50) | Destructive = Medium, other = Informational |
-| 5103 | High Deadlock Rate | High (20) / Medium (30) | >50/day = High, >9/day = Medium |
+| 5000 | Inadequate permissions | Informational (50) | `sys.traces` is not readable by the current login, so trace checks are skipped |
+| 5001 | Slow Data File / Log File Auto Grow | Medium (30) / Low (40) | Log grows = Medium, data grows = Low |
+| 5002 | Data File / Log File Auto Shrink | Low (40) | Harmful config actually executing |
+| 5003 | Potentially Disruptive DBCC Commands | Medium (30) / Informational (50) | Cache-clearing and WRITEPAGE = Medium, other = Informational |
+| 5004 | Default Trace Not Readable | Informational (50) | Default trace is registered but cannot be read; expected on Linux |
+| 5103 | High Number of Deadlocks | High (20) / Medium (30) | More than 50/day = High, more than 9/day = Medium |
+
+Checks 5001 through 5003 read the default trace and look back at most 7 days.
+Check 5004 exists so that a server where the default trace cannot be read is
+distinguishable from a server that genuinely has nothing to report: on Linux,
+`fn_trace_gettable` cannot read the default trace at all.
### Resource Performance
| Check | Finding | Priority | Description |
|-------|---------|----------|-------------|
-| 6001 | High Impact Wait Types | High (20) / Medium (30) / Low (40) | Top 10 waits by % of uptime |
-| 6002 | High Stolen Memory | High (20) / Medium (30) / Low (40) | Buffer pool starvation |
-| 6003 | Top Memory Consumers | Informational (50) | Top 5 non-buffer pool memory clerks |
-| 6101 | High Signal Wait Ratio | High (20) / Medium (30) / Low (40) | CPU scheduler contention |
-| 6102 | High SOS_SCHEDULER_YIELD | High (20) / Medium (30) / Low (40) | CPU pressure from frequent yields |
+| 6001 | Per-category wait findings | High (20) / Medium (30) / Low (40) | Top 10 waits by percent of uptime; see below |
+| 6002 | High Stolen Memory Percentage | High (20) / Medium (30) / Low (40) | Over 30% = High, over 15% = Medium |
+| 6003 | Top Memory Consumer | Informational (50) | Top 5 non-buffer-pool memory clerks using 1 GB or more |
+| 6101 | High Signal Wait Ratio | High (20) / Medium (30) / Low (40) | CPU scheduler contention; 50% = High, 30% = Medium, fires at 25% |
+| 6102 | High SOS_SCHEDULER_YIELD Waits | High (20) / Medium (30) / Low (40) | CPU pressure from frequent yields; same 50/30/25 thresholds |
+
+Check 6001 does not emit a generic "high wait" finding. It names each finding by
+what the wait means for the server: `Storage-Related Waits`, `Memory-Related Waits`,
+`Lock / Blocking Waits`, `TempDB Contention Waits`, `Transaction Log Waits`,
+`CPU / Scheduling Waits`, `Parallelism Waits`, `Query Execution Waits`,
+`Network / Client Waits`, `Availability Group Waits`, `Azure SQL Throttling Waits`,
+`Index Maintenance Waits`, `Statistics Update Waits`, or `Other Significant Waits`.
+The specific wait type and its plain-English meaning go in `object_name`.
+
+Severity is calibrated by category rather than applied flat. Resource-pressure
+waits (locking, memory, storage, tempdb, transaction log, CPU) reach High at
+`@wait_high_pct` of uptime or an average wait of 1 second, and Medium at
+`@wait_medium_pct` or an average wait of 250 ms. Parallelism waits are usually a
+cost threshold or MAXDOP symptom, so they need 100% of uptime just to reach
+Medium. Everything else stays Low.
### Database Configuration
@@ -133,38 +188,40 @@ Results include a `priority_label` column for readability and are sorted by prio
|-------|---------|----------|-------------|
| 7001 | Auto-Shrink Enabled | Medium (30) | Actively harmful config |
| 7002 | Auto-Close Enabled | Low (40) | Causes connection delays |
-| 7003 | Restricted Access Mode | High (20) | Apps can't connect |
-| 7004 | Auto Stats Disabled | Medium (30) | Causes stale statistics |
-| 7005 | ANSI Settings | Informational (50) | Non-standard ANSI settings |
+| 7003 | Restricted Access Mode | High (20) | Database is not in MULTI_USER mode, so apps cannot connect |
+| 7004 | Auto Create / Update Statistics Disabled | Medium (30) | Causes missing or stale statistics |
| 7006 | Query Store Not Enabled | Informational (50) | Missed opportunity |
-| 7007 | Non-Default Recovery Time | Informational (50) | Awareness |
| 7008 | Delayed Durability | Medium (30) | Data loss risk on crash |
-| 7009 | ADR Not Enabled | Low (40) | Recommendation for SI/RCSI databases |
+| 7009 | Accelerated Database Recovery Not Enabled With Snapshot Isolation | Low (40) | Recommendation for SI/RCSI databases; SQL Server 2019+ only |
| 7010 | Ledger Feature Enabled | Informational (50) | Awareness of overhead |
-| 7011 | Query Store State Mismatch | Medium (30) | QS not working as intended |
-| 7012 | Query Store Suboptimal Config | Low (40) | Tuning recommendation |
-| 7020 | Non-Default DB Scoped Config | Informational (50) | Awareness |
+| 7011 | Query Store State Mismatch | Medium (30) | Query Store is not in the state it was asked to be in |
+| 7012 | Query Store Suboptimal Configuration | Low (40) | Tuning recommendation |
+| 7020 | Non-Default Database Scoped Configuration | Informational (50) | Awareness |
### Database File Settings
| Check | Finding | Priority | Description |
|-------|---------|----------|-------------|
-| 7101 | % Growth on Data File | Low (40) | Reports growth % and current file size |
-| 7102 | % Growth on Log File | Medium (30) | Reports growth % and current file size |
-| 7103 | Non-Optimal Log Growth | Low (40) | Not 64 MB on SQL 2022+/Azure |
-| 7104 | Extremely Large Growth | Low (40) | Fixed growth >10 GB |
+| 7101 | Percentage Auto-Growth Setting on Data File | Low (40) | Reports growth percent and current file size |
+| 7102 | Percentage Auto-Growth Setting on Log File | Medium (30) | Reports growth percent and current file size |
+| 7103 | Non-Optimal Log File Growth Increment | Low (40) | Not exactly 64 MB on SQL Server 2022+, Azure SQL DB, or Azure MI |
+| 7104 | Extremely Large Auto-Growth Setting | Low (40) | Fixed growth over 10 GB |
## Results Organization
Results are organized by check_id ranges:
-- **1000-series**: Server configuration settings
-- **2000-series**: TempDB configuration
+- **1000-series**: Server configuration settings and global trace flags
+- **2000-series**: TempDB configuration and contention
- **3000-series**: Storage performance (file-level I/O)
- **4000-series**: Server health (memory, CPU, stability)
-- **5000-series**: Trace events (auto-growth, deadlocks, DBCC)
-- **6000-series**: Resource performance (waits, I/O, memory)
-- **7000-series**: Database configuration
+- **5000-series**: Default trace events (auto-growth, auto-shrink, DBCC) and deadlocks
+- **6000-series**: Resource performance (waits, CPU scheduling, memory)
+- **7000-series**: Database configuration, with 7100-series covering database file settings
+
+Findings in the `Errors` category are not health checks. They record that a
+collection step could not run (missing permissions, an unreadable DMV) so that a
+skipped check is never mistaken for a clean result.
Results are returned in two result sets:
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index 2da0e013..5f7b2af5 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -1,4 +1,4 @@
-SET ANSI_NULLS ON;
+SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
@@ -52,9 +52,17 @@ ALTER PROCEDURE
@database_name sysname = NULL, /* Database to check, NULL for all user databases */
@slow_read_ms decimal(10, 2) = 20.0, /* Flag data-file reads slower than this (ms); High at 5x */
@slow_write_ms decimal(10, 2) = 20.0, /* Flag data-file writes slower than this (ms); High at 5x */
- @significant_wait_threshold_pct decimal(5, 2) = 10.0, /* Minimum % of uptime for a wait to be reported */
- @wait_high_pct decimal(5, 2) = 50.0, /* Resource wait at/above this % of uptime is High */
- @wait_medium_pct decimal(5, 2) = 20.0, /* Resource wait at/above this % of uptime is Medium */
+ /*
+ decimal(38, 2), not decimal(5, 2). These compare against
+ #wait_stats.wait_time_percent_of_uptime, which is decimal(38, 2) because
+ cumulative wait time across all schedulers routinely runs to several
+ hundred or several thousand percent of wall-clock uptime on a many-core
+ server. decimal(5, 2) caps an override at 999.99, which cannot express a
+ threshold in the range the column actually reports.
+ */
+ @significant_wait_threshold_pct decimal(38, 2) = 10.0, /* Minimum % of uptime for a wait to be reported */
+ @wait_high_pct decimal(38, 2) = 50.0, /* Resource wait at/above this % of uptime is High */
+ @wait_medium_pct decimal(38, 2) = 20.0, /* Resource wait at/above this % of uptime is Medium */
@memory_grant_warning integer = 100, /* Forced grants at/above this count are Medium */
@memory_grant_critical integer = 10000, /* Forced grants at/above this count are High */
@help bit = 0, /*For helpfulness*/
@@ -202,19 +210,47 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
SELECT
@slow_read_ms =
- CASE WHEN @slow_read_ms >= 0 THEN @slow_read_ms ELSE 20.0 END,
+ CASE
+ WHEN @slow_read_ms >= 0
+ THEN @slow_read_ms
+ ELSE 20.0
+ END,
@slow_write_ms =
- CASE WHEN @slow_write_ms >= 0 THEN @slow_write_ms ELSE 20.0 END,
+ CASE
+ WHEN @slow_write_ms >= 0
+ THEN @slow_write_ms
+ ELSE 20.0
+ END,
@significant_wait_threshold_pct =
- CASE WHEN @significant_wait_threshold_pct >= 0 THEN @significant_wait_threshold_pct ELSE 10.0 END,
+ CASE
+ WHEN @significant_wait_threshold_pct >= 0
+ THEN @significant_wait_threshold_pct
+ ELSE 10.0
+ END,
@wait_high_pct =
- CASE WHEN @wait_high_pct >= 0 THEN @wait_high_pct ELSE 50.0 END,
+ CASE
+ WHEN @wait_high_pct >= 0
+ THEN @wait_high_pct
+ ELSE 50.0
+ END,
@wait_medium_pct =
- CASE WHEN @wait_medium_pct >= 0 THEN @wait_medium_pct ELSE 20.0 END,
+ CASE
+ WHEN @wait_medium_pct >= 0
+ THEN @wait_medium_pct
+ ELSE 20.0
+ END,
@memory_grant_warning =
- CASE WHEN @memory_grant_warning >= 0 THEN @memory_grant_warning ELSE 100 END,
+ CASE
+ WHEN @memory_grant_warning >= 0
+ THEN @memory_grant_warning
+ ELSE 100
+ END,
@memory_grant_critical =
- CASE WHEN @memory_grant_critical >= 0 THEN @memory_grant_critical ELSE 10000 END;
+ CASE
+ WHEN @memory_grant_critical >= 0
+ THEN @memory_grant_critical
+ ELSE 10000
+ END;
/*
Variable Declarations
@@ -551,7 +587,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
category nvarchar(50) NOT NULL,
finding nvarchar(200) NOT NULL,
database_name sysname NOT NULL DEFAULT N'N/A',
- object_name sysname NOT NULL DEFAULT N'N/A',
+ /*
+ Wide, not sysname. The storage checks (3001, 3002, 3003) put a file or
+ drive location in here: drive_location and physical_name are both
+ nvarchar(260) and hold the full blob storage URL for a data file on
+ Azure, and 3001/3002 append N' (' + type_desc + N')' on top of that.
+ sysname truncates at 128 characters and even 260 is not enough for the
+ longest path plus its suffix. Overflow raises Msg 8152 inside the outer
+ TRY, which kills the entire run rather than just the storage findings.
+ */
+ object_name nvarchar(500) NOT NULL DEFAULT N'N/A',
details nvarchar(4000) NULL,
url nvarchar(200) NULL
);
@@ -1128,12 +1173,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SELECT
check_id = 5103,
/*
- Rate is deadlocks-per-day, computed from DATEDIFF(SECOND, ...) rather than
- DATEDIFF(DAY, ...). The DAY-based version rounded sub-day uptime to 0 and
- the NULLIF then collapsed the whole expression to NULL, which evaluated as
- UNKNOWN in the WHERE below and silently skipped the deadlock check for the
- first calendar-day-boundary of server uptime. SECOND-based rate keeps the
- threshold semantics identical for any uptime ≥ 1 second.
+ Rate is deadlocks-per-day, and it has to be computed from
+ DATEDIFF(SECOND, ...) scaled by 86400, not DATEDIFF(DAY, ...).
+ A DAY-based denominator rounds sub-day uptime to 0, the NULLIF then
+ collapses the whole expression to NULL, and NULL evaluates as UNKNOWN
+ in the WHERE below, which silently skips the deadlock check entirely
+ until the server has been up across a calendar day boundary. The
+ SECOND-based rate is identical in threshold semantics for any uptime
+ of 1 second or more.
*/
priority =
CASE
@@ -1425,7 +1472,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
VALUES
(
9998,
- 90, /* Low priority informational */
+ 50, /* Informational: collection error */
N'Errors',
N'Error Capturing Trace Flags',
N'Unable to capture trace flags: ' + ERROR_MESSAGE()
@@ -2293,13 +2340,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
IF @has_view_server_state = 1
BEGIN
/* Calculate pagelatch wait time for TempDB contention check.
- Split into two scalar SELECTs — the previous version mixed an
- aggregated value (@pagelatch_wait_hours) with a non-aggregated
- one (@server_uptime_hours) in the same SELECT by joining
- wait_stats to sys_info and GROUP BY'ing on the uptime
- expression. It worked only because sys_info is always a
- single-row view, and the GROUP BY on a scalar expression
- reads oddly. */
+ Deliberately two scalar SELECTs. Assigning the aggregated value
+ (@pagelatch_wait_hours) and the non-aggregated one
+ (@server_uptime_hours) in a single SELECT would mean joining
+ wait_stats to sys_info and grouping by the uptime expression,
+ which only works because sys_info is always a single-row view
+ and reads as though the GROUP BY were meaningful. Keep them
+ separate. */
SELECT
@server_uptime_hours =
DATEDIFF(SECOND, osi.sqlserver_start_time, SYSDATETIME()) / 3600.0
@@ -2855,7 +2902,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ORDER BY
i.avg_io_stall_ms DESC;
- /* Check 6201 (High Database I/O Stalls) removed — duplicated by file-level checks 3001/3002/3003 */
+ /* Check 6201 (High Database I/O Stalls) removed: duplicated by file-level checks 3001/3002/3003 */
END;
/*
@@ -3736,7 +3783,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
VALUES
(
1002,
- 20, /* High priority — OS-starvation risk */
+ 20, /* High priority: OS-starvation risk */
N'Server Configuration',
N'Max Server Memory Too Close To Physical Memory',
N'Max server memory (' +
@@ -4621,7 +4668,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
WHERE qso.desired_state <> 0 /* Not intentionally OFF */
AND qso.readonly_reason <> 8 /* Ignore AG secondaries */
AND qso.desired_state <> qso.actual_state /* States don''t match */
- AND qso.actual_state IN (0, 1, 3); /* OFF(0), READ_ONLY(1), or ERROR(3) when it should be READ_WRITE - the comment used to say READ_ONLY but the list omitted state 1, so a Query Store auto-flipped to READ_ONLY because it filled up (readonly_reason 65536) never got flagged, its single most common failure. AG secondaries (readonly_reason 8) and intentional READ_ONLY (desired = actual) are already excluded above. */';
+ /*
+ OFF(0), READ_ONLY(1), or ERROR(3) while the desired state is
+ READ_WRITE. State 1 has to be in this list: a Query Store that
+ auto-flips to READ_ONLY because it filled up (readonly_reason
+ 65536) is its most common failure mode, and omitting 1 means that
+ case is never flagged. AG secondaries (readonly_reason 8) and
+ intentional READ_ONLY (desired = actual) are already excluded by
+ the predicates above.
+ */
+ AND qso.actual_state IN (0, 1, 3);';
IF @debug = 1
BEGIN
@@ -4821,16 +4877,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10, 13, 16, 17, 18, 19, 20, 24,
27, 28, 31, 33, 34, 35, 37, 39,
/*
- SQL Server 2025 options, with the real
- configuration_ids from the catalog. The IDs used
- to be 40, 41, 42, 43: 40/41 are actually
+ SQL Server 2025 options, using the real
+ configuration_ids from the catalog. 42, 44, and 47
+ are the three the CASE above actually evaluates.
+ 40 and 41 do not belong here: they are
READABLE_SECONDARY_TEMPORARY_STATS_AUTO_CREATE and
- _UPDATE, which have no entry in the CASE above, so
- they were pulled and flagged non-default on every
- 2025 database even at their default of 1; 43 does
- not exist; and the three options the CASE really
- evaluates (42, 44, 47) were never pulled, so they
- were silently never checked.
+ _UPDATE, which the CASE has no entry for, so pulling
+ them flags every 2025 database non-default even at
+ their default of 1. 43 does not exist.
*/
42, 44, 47
);
@@ -4944,45 +4998,45 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
with SI/RCSI enabled.
Gated on the column actually existing. Accelerated Database
- Recovery arrived in SQL Server 2019; on 2016/2017 the
+ Recovery arrived in SQL Server 2019; on 2016 and 2017 the
is_accelerated_database_recovery_on column does not exist, so the
- #databases builder hardcodes it to 0 for every database. Without
- this gate the check's "= 0" predicate was ALWAYS true and it
- recommended enabling ADR - a feature those versions do not have -
- for any database with snapshot isolation or RCSI on, which is
- most of them. Advice impossible to act on.
+ #databases builder hardcodes it to 0 for every database. The gate is
+ what keeps the "= 0" predicate below from being trivially true on
+ those versions, where the check would recommend enabling a feature
+ they do not have for every database running snapshot isolation or
+ RCSI.
*/
IF @has_is_accelerated_database_recovery = 1
BEGIN
- INSERT INTO
- #results
- (
- check_id,
- priority,
- category,
- finding,
- database_name,
- details,
- url
- )
- SELECT
- check_id = 7009,
- priority = 40, /* Low: recommendation */
- category = N'Database Configuration',
- finding = N'Accelerated Database Recovery Not Enabled With Snapshot Isolation',
- database_name = d.name,
- details =
- N'Database has Snapshot Isolation or RCSI enabled but Accelerated Database Recovery (ADR) is disabled. ' +
- N'ADR can significantly improve performance with these isolation levels by reducing version store cleanup overhead.',
- url = N'https://erikdarling.com/sp_perfcheck/#ADR'
- FROM #databases AS d
- WHERE d.database_id = @current_database_id
- AND d.is_accelerated_database_recovery_on = 0
- AND
- (
- d.snapshot_isolation_state_desc = N'ON'
- OR d.is_read_committed_snapshot_on = 1
- );
+ INSERT INTO
+ #results
+ (
+ check_id,
+ priority,
+ category,
+ finding,
+ database_name,
+ details,
+ url
+ )
+ SELECT
+ check_id = 7009,
+ priority = 40, /* Low: recommendation */
+ category = N'Database Configuration',
+ finding = N'Accelerated Database Recovery Not Enabled With Snapshot Isolation',
+ database_name = d.name,
+ details =
+ N'Database has Snapshot Isolation or RCSI enabled but Accelerated Database Recovery (ADR) is disabled. ' +
+ N'ADR can significantly improve performance with these isolation levels by reducing version store cleanup overhead.',
+ url = N'https://erikdarling.com/sp_perfcheck/#ADR'
+ FROM #databases AS d
+ WHERE d.database_id = @current_database_id
+ AND d.is_accelerated_database_recovery_on = 0
+ AND
+ (
+ d.snapshot_isolation_state_desc = N'ON'
+ OR d.is_read_committed_snapshot_on = 1
+ );
END;
/* Check if ledger is enabled */
diff --git a/sp_PerfCheck/tests/README.md b/sp_PerfCheck/tests/README.md
index ec6d3c80..09091672 100644
--- a/sp_PerfCheck/tests/README.md
+++ b/sp_PerfCheck/tests/README.md
@@ -26,7 +26,7 @@ the large surface it therefore does not cover.
| Script | What it does |
| --- | --- |
-| `run_tests.py` | Drives `dbo.sp_PerfCheck` over `sqlcmd`, parses the two result sets, and asserts **48** expectations across three groups: 11 structural/smoke, 26 forced server-configuration, 11 forced database-configuration. Self-cleaning and idempotent. |
+| `run_tests.py` | Drives `dbo.sp_PerfCheck` over `sqlcmd`, parses the two result sets, and asserts **39** expectations across four groups: 11 structural/smoke, 12 forced server-configuration, 11 forced database-configuration, 5 inaccessible-database regression. Self-cleaning and idempotent. |
```
cd sp_PerfCheck/tests
@@ -34,11 +34,9 @@ python run_tests.py --server SQL2022
```
`--server` and `--password` default to `SQL2022` / the standard local `sa`
-password. Expect `48 passed, 0 failed`. The proc must be installed in `master`
+password. Expect `39 passed, 0 failed`. The proc must be installed in `master`
on the target instance (there is a preflight that fails fast if it is not).
-The suite has been run green on SQL Server 2016, 2017, 2019, 2022, and 2025.
-
### What it covers
**1. Structural / smoke (11).** These catch crashes and output-shape regressions
@@ -53,31 +51,38 @@ across versions, and are always valid regardless of instance state:
`#server_info`);
- `@debug = 1` runs clean, prints diagnostics, and still completes.
-**2. Forced server-configuration (26).** The "Non-Default Configuration" check
-(`check_id 1000`) reads `sys.configurations`. For three options that are **safe
-to flip on a test instance** the harness proves the finding **bidirectionally**:
-
-| Option | Default | Forced to | Proven |
-| --- | --- | --- | --- |
-| `cost threshold for parallelism` | 5 | 55 | absent at 5, present at 55 |
-| `optimize for ad hoc workloads` | 0 | 1 | absent at 0, present at 1 |
-| `access check cache bucket count` | 0 | 256 | absent at 0, present at 256 |
-
-For each option the harness asserts the finding is absent at the default value,
-present when forced (with `check_id 1000`, `priority 50`,
-`category = Server Configuration`, and the option name plus forced value in
-`details`), and that no severe error occurred. Each **absence** assertion is
-paired with a **positive control** -- `#server_info` is populated on the same run
--- so an empty or failed result set cannot pass vacuously; the matching presence
-assertion is the proof that the check itself actually runs.
-
-These three options were chosen because none require a restart, none are
-dangerous (no `max server memory`, no affinity, no MAXDOP), and nothing the CI
-depends on reads them. Each option's **original `value_in_use` is captured first
-and restored precisely** in a `finally` block that runs even on assertion
-failure, and the harness dumps the entire `sys.configurations` table before and
-after and asserts **zero net change**. The suite is idempotent: run it twice,
-same result, no leaked config.
+**2. Forced server-configuration (12).** The "Cost Threshold for Parallelism Too
+Low" check (`check_id 1004`) reads `sys.configurations`, and cost threshold for
+parallelism is an advanced option that needs no restart, so it is safe to flip on
+a test instance. The harness drives it to three values and proves both the
+finding **and its severity escalation**:
+
+| `cost threshold for parallelism` | Expected |
+| --- | --- |
+| 55 (sane, >= 50) | finding **absent** |
+| 5 (the bad default) | finding **present** at High (priority 20) |
+| 25 (low, but not the default) | finding **present** at Low (priority 40) |
+
+The presence assertions also check the row is well formed (`check_id 1004`,
+`category = Server Configuration`) and that `details` names the configured value.
+The **absence** assertion is paired with a **positive control** -- `#server_info`
+is populated on the same run -- so an empty or failed result set cannot pass
+vacuously; the matching presence assertions are the proof that the check itself
+actually runs. The three-value design is what makes the graduated priority
+testable: a harness that only proved "fires below 50" would not notice the High
+and Low bands collapsing into one.
+
+The option's **original `value_in_use` is captured first and restored precisely**
+in a `finally` block that runs even on assertion failure, and the harness dumps
+the entire `sys.configurations` table before and after and asserts **zero net
+change**. `show advanced options` is set while configuring and restored the same
+way. The suite is idempotent: run it twice, same result, no leaked config.
+
+Before baselining, the harness issues a bare `RECONFIGURE` to flush any
+pre-existing pending config change. Some images ship one (the SQL Server 2017 CI
+container has `clr strict security` configured on but not yet in use), and
+without the flush the harness's own `RECONFIGURE` calls would apply that staged
+change mid-run and it would surface as a net change the harness never made.
**3. Forced database-configuration (11).** Two database-level checks read pure,
instantly-reversible metadata, so a **throwaway scratch database**
@@ -96,6 +101,12 @@ skipped database). Across the two runs each toggle is proven both present and
absent. The scratch database is dropped in a `finally` block, and setup is
idempotent (it drops any leaked copy first).
+**4. Inaccessible-database regression (5).** A separate scratch database
+(`perfcheck_test_inaccessible`) is closed with `AUTO_CLOSE` and then taken
+`OFFLINE`, and the harness asserts that a scoped run against each state, plus a
+**whole-instance run with the offline database present**, all complete without a
+severe error. See the section below for what this is guarding.
+
### Why the forced-condition assertions are trusted
Every forced-condition assertion was watched fail-to-fire at the non-triggering
@@ -117,44 +128,48 @@ means the covered surface works.
**Volatile runtime state (cannot be forced without destabilizing the box, and
would flake run-to-run):**
-- Offline CPU Schedulers (`check_id 935`)
-- Memory-Starved Queries Detected (`967`, `994`) -- depends on live resource
- semaphore waits
-- Memory Dumps Detected In Last 90 Days (`1029`) -- depends on dump history
-- High Number of Deadlocks (`1106`) -- depends on the deadlock counter
-- Large Security Token Cache (`1172`)
-- Slow Read Latency / Slow Write Latency (`2834`, `2877`) -- from
- `sys.dm_io_virtual_file_stats`; latency cannot be dialed to a threshold on
- demand
-- Everything in the **Wait Statistics** and **Memory Usage** categories --
+- Offline CPU Schedulers (`4001`)
+- Memory-Starved Queries: Forced Grants (`4101`) and Grant Timeouts (`4103`) --
+ both depend on live resource semaphore counters
+- Memory Dumps Detected In Last 90 Days (`4102`) -- depends on dump history
+- High Number of Deadlocks (`5103`) -- depends on the deadlock counter
+- Large Security Token Cache (`4104`)
+- Slow Read Latency / Slow Write Latency (`3001`, `3002`) and the drive-level
+ rollup (`3003`) -- from `sys.dm_io_virtual_file_stats`; latency cannot be
+ dialed to a threshold on demand
+- Everything in the **Wait Statistics** (`6001`), **CPU Scheduling**
+ (`6101`, `6102`), and **Memory Usage** (`6002`, `6003`) categories --
cumulative since restart, uncontrollable
+- The default-trace findings (`5001` slow auto-grow, `5002` auto-shrink events,
+ `5003` disruptive DBCC) -- these read events that already happened, and
+ `5000` / `5004` fire only when the trace cannot be read at all
**Environment / OS state (would require an OS privilege change and a service
restart, which CI cannot do):**
-- Lock Pages in Memory Not Enabled (`1220`)
-- Instant File Initialization Disabled (`1279`)
+- Lock Pages in Memory Not Enabled (`4105`)
+- Instant File Initialization Disabled (`4106`)
**Deliberately-not-touched because forcing them is disruptive, dangerous, or
requires a restart:**
-- TempDB Configuration checks (file count / size / growth parity) -- changing
- tempdb's file layout needs a restart to take effect and destabilizes a shared
- instance
-- Configuration Pending Reconfigure (`3826`) -- forcing it means deliberately
- leaving a `RECONFIGURE`-pending dirty state on the box
-- Resource Governor Enabled (`1322`) -- toggling Resource Governor affects
- workload classification for every session on the instance
-- `max server memory`, affinity masks, `max degree of parallelism`, priority
- boost, query governor cost limit -- excluded from the forced-config set even
- though check `1000` reads them, because they are dangerous, destabilizing, or
- something a real workload may depend on
+- TempDB Configuration checks (`2001`-`2006`, `2010`) -- changing tempdb's file
+ layout needs a restart to take effect and destabilizes a shared instance
+- Configuration Pending Reconfigure (`1007`) -- forcing it means deliberately
+ leaving a `RECONFIGURE`-pending dirty state on the box, which is exactly the
+ state the harness flushes before it starts
+- Notable Global Trace Flag (`1012`) -- setting a global trace flag with
+ `DBCC TRACEON (..., -1)` changes behavior for every session on the instance
+- `max server memory` (`1002`), affinity masks (`1008`-`1011`), `max degree of
+ parallelism` (`1003`), and priority boost (`1005`) -- dangerous,
+ destabilizing, or something a real workload may depend on
**Additional database-level checks that ARE forceable but are not asserted
-(representative coverage only):** Auto-Close Enabled (`7002`), Query Store Not
-Enabled (`7005` region), ANSI Settings Require Review, Non-Default Target
-Recovery Time, Delayed Durability, Accelerated Database Recovery / RCSI, Ledger,
-and the file auto-growth checks (`7101`-`7104`) could each be forced on a scratch
+(representative coverage only):** Auto-Close Enabled (`7002`), Restricted Access
+Mode (`7003`), Query Store Not Enabled (`7006`), Delayed Durability (`7008`),
+Accelerated Database Recovery with SI/RCSI (`7009`), Ledger (`7010`), Query Store
+health (`7011`, `7012`), non-default database scoped configurations (`7020`), and
+the file auto-growth checks (`7101`-`7104`) could each be forced on a scratch
database. The harness asserts a representative pair (Auto-Shrink, Auto Update
Statistics) to exercise the per-database cursor path and the `database_name`
scoping without turning the suite into an exhaustive per-setting matrix. See the
@@ -178,11 +193,17 @@ whole-instance run all complete without `Msg 515`. The database-scoped toggle
group uses `AUTO_UPDATE_STATISTICS` instead of `AUTO_CLOSE` for a separate reason
-- it keeps the database open, so the finding it forces is actually assessable.
-## Not wired into CI
+## Running in CI
+
+This suite runs in GitHub Actions as part of `.github/workflows/sql-tests.yml`,
+against SQL Server **2017, 2019, 2022, and 2025** containers. It reconfigures the
+instance and restores it, which is acceptable against a throwaway per-job
+container but is still a "run it on a box you control" tool by nature -- it always
+restores every option it touches and drops every database it creates, but do not
+point it at a shared instance somebody else depends on.
-Like the other DarlingData behavioral suites, this is **not** part of any GitHub
-Actions workflow -- it reconfigures a live instance (and briefly restores it),
-which is not something to run against shared CI infrastructure. Run it by hand
-against a test instance you own. It always restores every option it touches and
-drops every object it creates, but it is still a "run it on a box you control"
-tool by nature.
+CI overrides two environment variables the harness reads: `SQLCMD_BIN` (the path
+to the container's `sqlcmd`) and `SQLCMD_CONN_ARGS`, set to `-C -N disable` so the
+modern Go TLS stack does not reject the SQL Server 2017 container's self-signed
+certificate outright. Locally both default to plain `sqlcmd` on `PATH` with no
+extra connection arguments.
From 5afad26c6376237a36770ad8fc6e3b16ccec7f82 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Wed, 29 Jul 2026 00:43:45 -0400
Subject: [PATCH 60/60] Updates_20260801 release prep: bump all ten procs to .8
Every combined-install proc changed since Updates_20260701 moves to
the August minor and @version_date = 20260801: HealthParser 3.8,
HumanEvents 7.8, HumanEventsBlockViewer 5.8, IndexCleanup 2.8,
LogHunter 3.8, PerfCheck 2.8, PressureDetector 6.8, QueryReproBuilder
1.8, QuickieCache 1.8, QuickieStore 6.8. sp_QueryStoreCleanup is not
in the combined install and keeps its stamp.
Co-Authored-By: Claude Fable 5
---
sp_HealthParser/sp_HealthParser.sql | 4 ++--
sp_HumanEvents/sp_HumanEvents.sql | 4 ++--
sp_HumanEvents/sp_HumanEventsBlockViewer.sql | 4 ++--
sp_IndexCleanup/sp_IndexCleanup.sql | 4 ++--
sp_LogHunter/sp_LogHunter.sql | 4 ++--
sp_PerfCheck/sp_PerfCheck.sql | 4 ++--
sp_PressureDetector/sp_PressureDetector.sql | 4 ++--
sp_QueryReproBuilder/sp_QueryReproBuilder.sql | 4 ++--
sp_QuickieCache/sp_QuickieCache.sql | 4 ++--
sp_QuickieStore/sp_QuickieStore.sql | 4 ++--
10 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/sp_HealthParser/sp_HealthParser.sql b/sp_HealthParser/sp_HealthParser.sql
index 2a5f6063..503323cc 100644
--- a/sp_HealthParser/sp_HealthParser.sql
+++ b/sp_HealthParser/sp_HealthParser.sql
@@ -80,8 +80,8 @@ BEGIN
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- @version = '3.7',
- @version_date = '20260701';
+ @version = '3.8',
+ @version_date = '20260801';
IF @help = 1
BEGIN
diff --git a/sp_HumanEvents/sp_HumanEvents.sql b/sp_HumanEvents/sp_HumanEvents.sql
index 8a9b12c4..fd9e26cb 100644
--- a/sp_HumanEvents/sp_HumanEvents.sql
+++ b/sp_HumanEvents/sp_HumanEvents.sql
@@ -89,8 +89,8 @@ SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- @version = '7.7',
- @version_date = '20260701';
+ @version = '7.8',
+ @version_date = '20260801';
IF @help = 1
BEGIN
diff --git a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
index 29002c49..827c69e7 100644
--- a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
+++ b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql
@@ -94,8 +94,8 @@ SET XACT_ABORT OFF;
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- @version = '5.7',
- @version_date = '20260701';
+ @version = '5.8',
+ @version_date = '20260801';
IF @help = 1
BEGIN
diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql
index 52dd3162..49977943 100644
--- a/sp_IndexCleanup/sp_IndexCleanup.sql
+++ b/sp_IndexCleanup/sp_IndexCleanup.sql
@@ -75,8 +75,8 @@ BEGIN
SET NOCOUNT ON;
BEGIN TRY
SELECT
- @version = '2.7',
- @version_date = '20260701';
+ @version = '2.8',
+ @version_date = '20260801';
IF
/* Check SQL Server 2012+ for FORMAT and CONCAT functions */
diff --git a/sp_LogHunter/sp_LogHunter.sql b/sp_LogHunter/sp_LogHunter.sql
index 1a5439a7..170b4dab 100644
--- a/sp_LogHunter/sp_LogHunter.sql
+++ b/sp_LogHunter/sp_LogHunter.sql
@@ -73,8 +73,8 @@ SET DATEFORMAT MDY;
BEGIN
SELECT
- @version = '3.7',
- @version_date = '20260701';
+ @version = '3.8',
+ @version_date = '20260801';
IF @help = 1
BEGIN
diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql
index 5f7b2af5..c1529a42 100644
--- a/sp_PerfCheck/sp_PerfCheck.sql
+++ b/sp_PerfCheck/sp_PerfCheck.sql
@@ -81,8 +81,8 @@ BEGIN
Set version information
*/
SELECT
- @version = N'2.7',
- @version_date = N'20260701';
+ @version = N'2.8',
+ @version_date = N'20260801';
/*
Help section, for help.
diff --git a/sp_PressureDetector/sp_PressureDetector.sql b/sp_PressureDetector/sp_PressureDetector.sql
index d2d2d99e..eb14363c 100644
--- a/sp_PressureDetector/sp_PressureDetector.sql
+++ b/sp_PressureDetector/sp_PressureDetector.sql
@@ -78,8 +78,8 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SET LANGUAGE us_english;
SELECT
- @version = '6.7',
- @version_date = '20260701';
+ @version = '6.8',
+ @version_date = '20260801';
IF @help = 1
diff --git a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
index f95aebdd..99b37f15 100644
--- a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
+++ b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql
@@ -90,8 +90,8 @@ BEGIN TRY
/*Version*/
SELECT
- @version = '1.7',
- @version_date = '20260701';
+ @version = '1.8',
+ @version_date = '20260801';
/*Help*/
IF @help = 1
diff --git a/sp_QuickieCache/sp_QuickieCache.sql b/sp_QuickieCache/sp_QuickieCache.sql
index 9a36cab4..baa28ebf 100644
--- a/sp_QuickieCache/sp_QuickieCache.sql
+++ b/sp_QuickieCache/sp_QuickieCache.sql
@@ -78,8 +78,8 @@ BEGIN
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
- @version = '1.7',
- @version_date = '20260701';
+ @version = '1.8',
+ @version_date = '20260801';
/*
╔══════════════════════════════════════════════════╗
diff --git a/sp_QuickieStore/sp_QuickieStore.sql b/sp_QuickieStore/sp_QuickieStore.sql
index f8fcf1b1..4fe20faf 100644
--- a/sp_QuickieStore/sp_QuickieStore.sql
+++ b/sp_QuickieStore/sp_QuickieStore.sql
@@ -128,8 +128,8 @@ BEGIN TRY
These are for your outputs.
*/
SELECT
- @version = '6.7',
- @version_date = '20260701';
+ @version = '6.8',
+ @version_date = '20260801';
/*
Helpful section! For help.