From 842f6d0772b48d32dda4e32dd81e5ee2c2d699f7 Mon Sep 17 00:00:00 2001 From: RG Date: Sat, 5 Apr 2025 20:57:17 +0100 Subject: [PATCH] Added reports of resumable index operations (paused or running) to the mode 0 and mode 4 outputs, as well as the mode where you name one table. The priority set here is very high, but that seems correct. They can prevent DDL! Not included: Any reference to the 2022 feature that auto-kills paused indexes. It is in the ALTER DATABASE SCOPED CONFIGURATION docs. I will make an issue for it. --- .../sp_BlitzIndex_Checks_by_Priority.md | 6 +- sp_BlitzIndex.sql | 241 +++++++++++++++++- 2 files changed, 240 insertions(+), 7 deletions(-) diff --git a/Documentation/sp_BlitzIndex_Checks_by_Priority.md b/Documentation/sp_BlitzIndex_Checks_by_Priority.md index 3cc69a7f4..d6af3eec2 100644 --- a/Documentation/sp_BlitzIndex_Checks_by_Priority.md +++ b/Documentation/sp_BlitzIndex_Checks_by_Priority.md @@ -6,13 +6,15 @@ Before adding a new check, make sure to add a Github issue for it first, and hav If you want to change anything about a check - the priority, finding, URL, or ID - open a Github issue first. The relevant scripts have to be updated too. -CURRENT HIGH CHECKID: 121 -If you want to add a new check, start at 122. +CURRENT HIGH CHECKID: 123 +If you want to add a new check, start at 124. | Priority | FindingsGroup | Finding | URL | CheckID | | -------- | ----------------------- | --------------------------------------------------------------- | ----------------------------------------------- | ------- | | 10 | Over-Indexing | Many NC Indexes on a Single Table | https://www.brentozar.com/go/IndexHoarder | 20 | | 10 | Over-Indexing | Unused NC Index with High Writes | https://www.brentozar.com/go/IndexHoarder | 22 | +| 10 | Resumable Indexing | Resumable Index Operation Paused | https://www.BrentOzar.com/go/resumable | 122 | +| 10 | Resumable Indexing | Resumable Index Operation Running | https://www.BrentOzar.com/go/resumable | 123 | | 20 | Redundant Indexes | Duplicate Keys | https://www.brentozar.com/go/duplicateindex | 1 | | 30 | Redundant Indexes | Approximate Duplicate Keys | https://www.brentozar.com/go/duplicateindex | 2 | | 40 | Index Suggestion | High Value Missing Index | https://www.brentozar.com/go/indexaphobia | 50 | diff --git a/sp_BlitzIndex.sql b/sp_BlitzIndex.sql index 01cd26ad9..6ae4e5968 100644 --- a/sp_BlitzIndex.sql +++ b/sp_BlitzIndex.sql @@ -261,8 +261,11 @@ IF OBJECT_ID('tempdb..#FilteredIndexes') IS NOT NULL DROP TABLE #FilteredIndexes; IF OBJECT_ID('tempdb..#Ignore_Databases') IS NOT NULL - DROP TABLE #Ignore_Databases - + DROP TABLE #Ignore_Databases; + +IF OBJECT_ID('tempdb..#IndexResumableOperations') IS NOT NULL + DROP TABLE #IndexResumableOperations; + IF OBJECT_ID('tempdb..#dm_db_partition_stats_etc') IS NOT NULL DROP TABLE #dm_db_partition_stats_etc IF OBJECT_ID('tempdb..#dm_db_index_operational_stats') IS NOT NULL @@ -803,6 +806,59 @@ IF OBJECT_ID('tempdb..#dm_db_index_operational_stats') IS NOT NULL column_name NVARCHAR(128) NULL ); + CREATE TABLE #IndexResumableOperations + ( + database_name NVARCHAR(128) NULL, + database_id INT NOT NULL, + schema_name NVARCHAR(128) NOT NULL, + table_name NVARCHAR(128) NOT NULL, + /* + Every following non-computed column has + the same definitions as in + sys.index_resumable_operations. + */ + [object_id] INT NOT NULL, + index_id INT NOT NULL, + [name] NVARCHAR(128) NOT NULL, + /* + We have done nothing to make this query text pleasant + to read. Until somebody has a better idea, we trust + that copying Microsoft's approach is wise. + */ + sql_text NVARCHAR(MAX) NULL, + last_max_dop_used SMALLINT NOT NULL, + partition_number INT NULL, + state TINYINT NOT NULL, + state_desc NVARCHAR(60) NULL, + start_time DATETIME NOT NULL, + last_pause_time DATETIME NULL, + total_execution_time INT NOT NULL, + percent_complete FLOAT NOT NULL, + page_count BIGINT NOT NULL, + /* + sys.indexes will not always have the name of the index + because a resumable CREATE INDEX does not populate + sys.indexes until it is done. + So it is better to work out the full name here + rather than pull it from another temp table. + */ + [db_schema_table_index] AS + [schema_name] + N'.' + [table_name] + N'.' + [name], + /* For convenience. */ + reserved_MB_pretty_print AS + CONVERT(NVARCHAR(100), CONVERT(MONEY, page_count * 8. / 1024.)) + + 'MB and ' + + state_desc, + more_info AS + N'New index: SELECT * FROM ' + QUOTENAME(database_name) + + N'.sys.index_resumable_operations WHERE [object_id] = ' + + CONVERT(NVARCHAR(100), [object_id]) + + N'; Old index: ' + + N'EXEC dbo.sp_BlitzIndex @DatabaseName=' + QUOTENAME([database_name],N'''') + + N', @SchemaName=' + QUOTENAME([schema_name],N'''') + + N', @TableName=' + QUOTENAME([table_name],N'''') + N';' + ); + CREATE TABLE #Ignore_Databases ( DatabaseName NVARCHAR(128), @@ -2517,8 +2573,53 @@ OPTION (RECOMPILE);'; BEGIN CATCH RAISERROR (N'Skipping #FilteredIndexes population due to error, typically low permissions.', 0,1) WITH NOWAIT; END CATCH + END; + + IF @Mode NOT IN(1, 2, 3) + /* + The sys.index_resumable_operations view was a 2017 addition, so we need to check for it and go dynamic. + */ + AND EXISTS (SELECT * FROM sys.all_objects WHERE name = 'index_resumable_operations') + BEGIN + SET @dsql=N'SELECT @i_DatabaseName AS database_name, + DB_ID(@i_DatabaseName) AS [database_id], + s.name AS schema_name, + t.name AS table_name, + iro.[object_id], + iro.index_id, + iro.name, + iro.sql_text, + iro.last_max_dop_used, + iro.partition_number, + iro.state, + iro.state_desc, + iro.start_time, + iro.last_pause_time, + iro.total_execution_time, + iro.percent_complete, + iro.page_count + FROM ' + QUOTENAME(@DatabaseName) + N'.sys.index_resumable_operations AS iro + JOIN ' + QUOTENAME(@DatabaseName) + N'.sys.tables AS t + ON t.object_id = iro.object_id + JOIN ' + QUOTENAME(@DatabaseName) + N'.sys.schemas AS s + ON t.schema_id = s.schema_id + OPTION(RECOMPILE);' + + BEGIN TRY + RAISERROR (N'Inserting data into #IndexResumableOperations',0,1) WITH NOWAIT; + INSERT #IndexResumableOperations + ( database_name, database_id, schema_name, table_name, + [object_id], index_id, name, sql_text, last_max_dop_used, partition_number, state, state_desc, + start_time, last_pause_time, total_execution_time, percent_complete, page_count ) + EXEC sp_executesql @dsql, @params = N'@i_DatabaseName NVARCHAR(128)', @i_DatabaseName = @DatabaseName; + END TRY + BEGIN CATCH + RAISERROR (N'Skipping #IndexResumableOperations population due to error, typically low permissions', 0,1) WITH NOWAIT; + END CATCH + END; + + END; - END; END; END TRY @@ -2967,7 +3068,8 @@ BEGIN SELECT '#ComputedColumns' AS table_name, * FROM #ComputedColumns; SELECT '#TraceStatus' AS table_name, * FROM #TraceStatus; SELECT '#CheckConstraints' AS table_name, * FROM #CheckConstraints; - SELECT '#FilteredIndexes' AS table_name, * FROM #FilteredIndexes; + SELECT '#FilteredIndexes' AS table_name, * FROM #FilteredIndexes; + SELECT '#IndexResumableOperations' AS table_name, * FROM #IndexResumableOperations; END @@ -3185,7 +3287,50 @@ BEGIN ORDER BY s.auto_created, s.user_created, s.name, hist.step_number;'; EXEC sp_executesql @dsql, N'@ObjectID INT', @ObjectID; END - END + + /* Check for resumable index operations. */ + IF (SELECT TOP (1) [object_id] FROM #IndexResumableOperations WHERE [object_id] = @ObjectID AND database_id = @DatabaseID) IS NOT NULL + BEGIN + SELECT + N'Resumable Index Operation' AS finding, + N'This may invalidate your analysis!' AS warning, + iro.state_desc + ' on ' + iro.db_schema_table_index + + CASE iro.state + WHEN 0 THEN + ' at MAXDOP ' + CONVERT(NVARCHAR(30), iro.last_max_dop_used) + + '. First started ' + CONVERT(NVARCHAR(50), iro.start_time, 120) + '. ' + + CONVERT(NVARCHAR(6), CONVERT(MONEY, iro.percent_complete)) + '% complete after ' + + CONVERT(NVARCHAR(30), iro.total_execution_time) + + ' minute(s). This blocks DDL and can pile up ghosts.' + WHEN 1 THEN + ' since ' + CONVERT(NVARCHAR(50), iro.last_pause_time, 120) + '. ' + + CONVERT(NVARCHAR(6), CONVERT(MONEY, iro.percent_complete)) + '% complete' + + /* + At 100% completion, resumable indexes open up a transaction and go back to paused for what ought to be a moment. + Updating statistics is one of the things that it can do in this false paused state. + Updating stats can take a while, so we point it out as a likely delay. + It seems that any of the normal operations that happen at the very end of an index build can cause this. + */ + CASE WHEN iro.percent_complete > 99.9 + THEN '. It is probably still running, perhaps updating statistics.' + ELSE ' after ' + CONVERT(NVARCHAR(30), iro.total_execution_time) + + ' minute(s). This blocks DDL, fails transactions needing table-level X locks, and can pile up ghosts.' + END + ELSE ' which is an undocumented resumable index state description.' + END AS details, + N'https://www.BrentOzar.com/go/resumable' AS URL, + iro.more_info AS [More Info] + FROM #IndexResumableOperations AS iro + WHERE iro.database_id = @DatabaseID + AND iro.[object_id] = @ObjectID + OPTION ( RECOMPILE ); + END + ELSE + BEGIN + SELECT N'No resumable index operations.' AS finding; + END; + + END /* END @ShowColumnstoreOnly = 0 */ /* Visualize columnstore index contents. More info: https://github.com/BrentOzarULTD/SQL-Server-First-Responder-Kit/issues/2584 */ IF 2 = (SELECT SUM(1) FROM sys.all_objects WHERE name IN ('column_store_row_groups','column_store_segments')) @@ -3566,6 +3711,92 @@ BEGIN ORDER BY ips.total_rows DESC, ip.[schema_name], ip.[object_name], ip.key_column_names, ip.include_column_names OPTION ( RECOMPILE ); + ---------------------------------------- + --Resumable Indexing: Check_id 122-123 + ---------------------------------------- + /* + This is more complicated than you would expect! + As of SQL Server 2022, I am aware of 6 cases that we need to check: + 1) A resumable rowstore CREATE INDEX that is currently running + 2) A resumable rowstore CREATE INDEX that is currently paused + 3) A resumable rowstore REBUILD that is currently running + 4) A resumable rowstore REBUILD that is currently paused + 5) A resumable rowstore CREATE INDEX [...] DROP_EXISTING = ON that is currently running + 6) A resumable rowstore CREATE INDEX [...] DROP_EXISTING = ON that is currently paused + In cases 1 and 2, sys.indexes has no data at all about the index in question. + This makes #IndexSanity much harder to use, since it depends on sys.indexes. + We must therefore get as much from #IndexResumableOperations as possible. + */ + RAISERROR(N'check_id 122: Resumable Index Operation Paused', 0,1) WITH NOWAIT; + INSERT #BlitzIndexResults ( check_id, index_sanity_id, Priority, findings_group, finding, + [database_name], URL, details, index_definition, secret_columns, + index_usage_summary, index_size_summary, create_tsql, more_info ) + SELECT 122 AS check_id, + i.index_sanity_id, + 10 AS Priority, + N'Resumable Indexing' AS findings_group, + N'Resumable Index Operation Paused' AS finding, + iro.[database_name] AS [Database Name], + N'https://www.BrentOzar.com/go/resumable' AS URL, + iro.state_desc + ' on ' + iro.db_schema_table_index + + ' since ' + CONVERT(NVARCHAR(50), iro.last_pause_time, 120) + '. ' + + CONVERT(NVARCHAR(6), CONVERT(MONEY, iro.percent_complete)) + '% complete' + + /* + At 100% completion, resumable indexes open up a transaction and go back to paused for what ought to be a moment. + Updating statistics is one of the things that it can do in this false paused state. + Updating stats can take a while, so we point it out as a likely delay. + It seems that any of the normal operations that happen at the very end of an index build can cause this. + */ + CASE WHEN iro.percent_complete > 99.9 + THEN '. It is probably still running, perhaps updating statistics.' + ELSE ' after ' + CONVERT(NVARCHAR(30), iro.total_execution_time) + + ' minute(s). This blocks DDL, fails transactions needing table-level X locks, and can pile up ghosts.' + END AS details, + 'Old index: ' + ISNULL(i.index_definition, 'not found. Either the index is new or you need @IncludeInactiveIndexes = 1') AS index_definition, + i.secret_columns, + i.index_usage_summary, + 'New index: ' + iro.reserved_MB_pretty_print + '; Old index: ' + ISNULL(sz.index_size_summary,'not found.') AS index_size_summary, + 'New index: ' + iro.sql_text AS create_tsql, + iro.more_info + FROM #IndexResumableOperations iro + LEFT JOIN #IndexSanity AS i ON i.database_id = iro.database_id + AND i.[object_id] = iro.[object_id] + AND i.index_id = iro.index_id + LEFT JOIN #IndexSanitySize sz ON i.index_sanity_id = sz.index_sanity_id + WHERE iro.state = 1 + OPTION ( RECOMPILE ); + + RAISERROR(N'check_id 123: Resumable Index Operation Running', 0,1) WITH NOWAIT; + INSERT #BlitzIndexResults ( check_id, index_sanity_id, Priority, findings_group, finding, + [database_name], URL, details, index_definition, secret_columns, + index_usage_summary, index_size_summary, create_tsql, more_info ) + SELECT 123 AS check_id, + i.index_sanity_id, + 10 AS Priority, + N'Resumable Indexing' AS findings_group, + N'Resumable Index Operation Running' AS finding, + iro.[database_name] AS [Database Name], + N'https://www.BrentOzar.com/go/resumable' AS URL, + iro.state_desc + ' on ' + iro.db_schema_table_index + + ' at MAXDOP ' + CONVERT(NVARCHAR(30), iro.last_max_dop_used) + + '. First started ' + CONVERT(NVARCHAR(50), iro.start_time, 120) + '. ' + + CONVERT(NVARCHAR(6), CONVERT(MONEY, iro.percent_complete)) + '% complete after ' + + CONVERT(NVARCHAR(30), iro.total_execution_time) + + ' minute(s). This blocks DDL and can pile up ghosts.' AS details, + 'Old index: ' + ISNULL(i.index_definition, 'not found. Either the index is new or you need @IncludeInactiveIndexes = 1') AS index_definition, + i.secret_columns, + i.index_usage_summary, + 'New index: ' + iro.reserved_MB_pretty_print + '; Old index: ' + ISNULL(sz.index_size_summary,'not found.') AS index_size_summary, + 'New index: ' + iro.sql_text AS create_tsql, + iro.more_info + FROM #IndexResumableOperations iro + LEFT JOIN #IndexSanity AS i ON i.database_id = iro.database_id + AND i.[object_id] = iro.[object_id] + AND i.index_id = iro.index_id + LEFT JOIN #IndexSanitySize sz ON i.index_sanity_id = sz.index_sanity_id + WHERE iro.state = 0 + OPTION ( RECOMPILE ); + ---------------------------------------- --Aggressive Indexes: Check_id 10-19 ----------------------------------------