Skip to content

Commit 694ead6

Browse files
Add nonclustered indexes for query/procedure/query store lookups
Phase 3 OUTER APPLY hydration of compressed query_text/plan_text was forcing an Eager Index Spool over the full collect.query_stats table (and similar for procedure_stats / query_store_data), which took 104 seconds on a 742K-row table in #835. Changes: - Remove CONVERT(binary(8), nvarchar-hash, 1) anti-pattern from OUTER APPLY WHERE clauses by keeping query_hash as native binary(8) in temp tables. query_hash is only converted to nvarchar(20) in the final output projection. - Add three nonclustered indexes (install script and upgrade script): IX_query_stats_hash_lookup (query_hash, database_name, collection_time DESC) IX_procedure_stats_name_lookup (database_name, schema_name, object_name, collection_time DESC) IX_query_store_data_id_lookup (database_name, query_id, collection_time DESC) - Indexes use SORT_IN_TEMPDB = ON and DATA_COMPRESSION = PAGE. - ONLINE = ON is applied conditionally via dynamic SQL based on SERVERPROPERTY('EngineEdition') — Enterprise/Developer/Azure only, since Standard/Web/Express don't support online index operations. Tested against CADelete's 742K-row table: Phase 3 went from 104s to well under 1s (5s total for the full three-phase query). Fixes #835 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d5f586c commit 694ead6

4 files changed

Lines changed: 184 additions & 7 deletions

File tree

Dashboard/Services/DatabaseService.QueryPerformance.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,7 +1123,7 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE')
11231123
11241124
SELECT TOP (500)
11251125
database_name = pl.database_name,
1126-
query_hash = CONVERT(nvarchar(20), pl.query_hash, 1),
1126+
query_hash = pl.query_hash,
11271127
object_type = MAX(pl.object_type),
11281128
object_name =
11291129
CASE MAX(pl.object_type)
@@ -1180,7 +1180,7 @@ HASH GROUP
11801180
/*Phase 3: hydrate text and plan XML for the TOP 500 winners only*/
11811181
SELECT
11821182
tr.database_name,
1183-
tr.query_hash,
1183+
query_hash = CONVERT(nvarchar(20), tr.query_hash, 1),
11841184
tr.object_type,
11851185
tr.object_name,
11861186
tr.first_execution_time,
@@ -1224,7 +1224,7 @@ OUTER APPLY
12241224
SELECT TOP (1)
12251225
query_text = CAST(DECOMPRESS(qs2.query_text) AS nvarchar(max))
12261226
FROM collect.query_stats AS qs2
1227-
WHERE qs2.query_hash = CONVERT(binary(8), tr.query_hash, 1)
1227+
WHERE qs2.query_hash = tr.query_hash
12281228
AND qs2.database_name = tr.database_name
12291229
ORDER BY qs2.collection_time DESC
12301230
) AS qt
@@ -1233,7 +1233,7 @@ OUTER APPLY
12331233
SELECT TOP (1)
12341234
query_plan_xml = CAST(DECOMPRESS(qs3.query_plan_text) AS nvarchar(max))
12351235
FROM collect.query_stats AS qs3
1236-
WHERE qs3.query_hash = CONVERT(binary(8), tr.query_hash, 1)
1236+
WHERE qs3.query_hash = tr.query_hash
12371237
AND qs3.database_name = tr.database_name
12381238
AND qs3.query_plan_text IS NOT NULL
12391239
ORDER BY qs3.collection_time DESC
@@ -4379,7 +4379,7 @@ USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE')
43794379
43804380
SELECT TOP (@top + 5)
43814381
database_name = pl.database_name,
4382-
query_hash = CONVERT(nvarchar(20), pl.query_hash, 1),
4382+
query_hash = pl.query_hash,
43834383
object_type = MAX(pl.object_type),
43844384
object_name =
43854385
CASE MAX(pl.object_type)
@@ -4438,7 +4438,7 @@ HASH GROUP
44384438
/*Phase 3: hydrate text for winners only, apply WAITFOR filter*/
44394439
SELECT TOP (@top)
44404440
tr.database_name,
4441-
tr.query_hash,
4441+
query_hash = CONVERT(nvarchar(20), tr.query_hash, 1),
44424442
tr.object_type,
44434443
tr.object_name,
44444444
tr.first_execution_time,
@@ -4481,7 +4481,7 @@ OUTER APPLY
44814481
SELECT TOP (1)
44824482
query_text = CAST(DECOMPRESS(qs2.query_text) AS nvarchar(max))
44834483
FROM collect.query_stats AS qs2
4484-
WHERE qs2.query_hash = CONVERT(binary(8), tr.query_hash, 1)
4484+
WHERE qs2.query_hash = tr.query_hash
44854485
AND qs2.database_name = tr.database_name
44864486
ORDER BY qs2.collection_time DESC
44874487
) AS qt

install/02_create_tables.sql

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,5 +1495,83 @@ BEGIN
14951495
PRINT 'Created collect.server_properties table';
14961496
END;
14971497

1498+
/*
1499+
Nonclustered indexes to support OUTER APPLY lookups in the Dashboard's
1500+
query/procedure/query store grid queries. Without these, the optimizer builds
1501+
an Eager Index Spool over the entire table to service the lookups, which can
1502+
take minutes on large installations (see #835).
1503+
1504+
ONLINE = ON is only supported on Enterprise/Developer/Azure editions. The
1505+
options string is built dynamically based on SERVERPROPERTY('EngineEdition').
1506+
*/
1507+
DECLARE @online_option nvarchar(20) =
1508+
CASE
1509+
WHEN CAST(SERVERPROPERTY(N'EngineEdition') AS integer) IN (3, 5, 8)
1510+
THEN N', ONLINE = ON'
1511+
ELSE N''
1512+
END;
1513+
1514+
DECLARE @index_sql nvarchar(max);
1515+
1516+
IF NOT EXISTS
1517+
(
1518+
SELECT
1519+
1/0
1520+
FROM sys.indexes
1521+
WHERE object_id = OBJECT_ID(N'collect.query_stats')
1522+
AND name = N'IX_query_stats_hash_lookup'
1523+
)
1524+
BEGIN
1525+
SET @index_sql = N'
1526+
CREATE NONCLUSTERED INDEX
1527+
IX_query_stats_hash_lookup
1528+
ON collect.query_stats
1529+
(query_hash, database_name, collection_time DESC)
1530+
WITH
1531+
(SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE' + @online_option + N');';
1532+
EXEC sys.sp_executesql @index_sql;
1533+
PRINT 'Created collect.query_stats.IX_query_stats_hash_lookup index';
1534+
END;
1535+
1536+
IF NOT EXISTS
1537+
(
1538+
SELECT
1539+
1/0
1540+
FROM sys.indexes
1541+
WHERE object_id = OBJECT_ID(N'collect.procedure_stats')
1542+
AND name = N'IX_procedure_stats_name_lookup'
1543+
)
1544+
BEGIN
1545+
SET @index_sql = N'
1546+
CREATE NONCLUSTERED INDEX
1547+
IX_procedure_stats_name_lookup
1548+
ON collect.procedure_stats
1549+
(database_name, schema_name, object_name, collection_time DESC)
1550+
WITH
1551+
(SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE' + @online_option + N');';
1552+
EXEC sys.sp_executesql @index_sql;
1553+
PRINT 'Created collect.procedure_stats.IX_procedure_stats_name_lookup index';
1554+
END;
1555+
1556+
IF NOT EXISTS
1557+
(
1558+
SELECT
1559+
1/0
1560+
FROM sys.indexes
1561+
WHERE object_id = OBJECT_ID(N'collect.query_store_data')
1562+
AND name = N'IX_query_store_data_id_lookup'
1563+
)
1564+
BEGIN
1565+
SET @index_sql = N'
1566+
CREATE NONCLUSTERED INDEX
1567+
IX_query_store_data_id_lookup
1568+
ON collect.query_store_data
1569+
(database_name, query_id, collection_time DESC)
1570+
WITH
1571+
(SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE' + @online_option + N');';
1572+
EXEC sys.sp_executesql @index_sql;
1573+
PRINT 'Created collect.query_store_data.IX_query_store_data_id_lookup index';
1574+
END;
1575+
14981576
PRINT 'All collection tables created successfully';
14991577
GO
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
Copyright 2026 Darling Data, LLC
3+
https://www.erikdarling.com/
4+
5+
Upgrade from 2.7.0 to 2.8.0
6+
7+
Add nonclustered indexes on collect.query_stats, collect.procedure_stats, and
8+
collect.query_store_data to support OUTER APPLY lookups in the Dashboard's
9+
grid queries. Without these, the optimizer builds an Eager Index Spool over
10+
the entire table to service the lookups, which can take minutes on large
11+
installations (see #835).
12+
13+
ONLINE = ON is only supported on Enterprise/Developer/Azure editions. The
14+
options string is built dynamically based on SERVERPROPERTY('EngineEdition').
15+
*/
16+
17+
SET ANSI_NULLS ON;
18+
SET ANSI_PADDING ON;
19+
SET ANSI_WARNINGS ON;
20+
SET ARITHABORT ON;
21+
SET CONCAT_NULL_YIELDS_NULL ON;
22+
SET QUOTED_IDENTIFIER ON;
23+
SET NUMERIC_ROUNDABORT OFF;
24+
SET IMPLICIT_TRANSACTIONS OFF;
25+
SET STATISTICS TIME, IO OFF;
26+
GO
27+
28+
USE PerformanceMonitor;
29+
GO
30+
31+
DECLARE @online_option nvarchar(20) =
32+
CASE
33+
WHEN CAST(SERVERPROPERTY(N'EngineEdition') AS integer) IN (3, 5, 8)
34+
THEN N', ONLINE = ON'
35+
ELSE N''
36+
END;
37+
38+
DECLARE @index_sql nvarchar(max);
39+
40+
IF NOT EXISTS
41+
(
42+
SELECT
43+
1/0
44+
FROM sys.indexes
45+
WHERE object_id = OBJECT_ID(N'collect.query_stats')
46+
AND name = N'IX_query_stats_hash_lookup'
47+
)
48+
BEGIN
49+
SET @index_sql = N'
50+
CREATE NONCLUSTERED INDEX
51+
IX_query_stats_hash_lookup
52+
ON collect.query_stats
53+
(query_hash, database_name, collection_time DESC)
54+
WITH
55+
(SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE' + @online_option + N');';
56+
EXEC sys.sp_executesql @index_sql;
57+
PRINT 'Created collect.query_stats.IX_query_stats_hash_lookup index';
58+
END;
59+
60+
IF NOT EXISTS
61+
(
62+
SELECT
63+
1/0
64+
FROM sys.indexes
65+
WHERE object_id = OBJECT_ID(N'collect.procedure_stats')
66+
AND name = N'IX_procedure_stats_name_lookup'
67+
)
68+
BEGIN
69+
SET @index_sql = N'
70+
CREATE NONCLUSTERED INDEX
71+
IX_procedure_stats_name_lookup
72+
ON collect.procedure_stats
73+
(database_name, schema_name, object_name, collection_time DESC)
74+
WITH
75+
(SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE' + @online_option + N');';
76+
EXEC sys.sp_executesql @index_sql;
77+
PRINT 'Created collect.procedure_stats.IX_procedure_stats_name_lookup index';
78+
END;
79+
80+
IF NOT EXISTS
81+
(
82+
SELECT
83+
1/0
84+
FROM sys.indexes
85+
WHERE object_id = OBJECT_ID(N'collect.query_store_data')
86+
AND name = N'IX_query_store_data_id_lookup'
87+
)
88+
BEGIN
89+
SET @index_sql = N'
90+
CREATE NONCLUSTERED INDEX
91+
IX_query_store_data_id_lookup
92+
ON collect.query_store_data
93+
(database_name, query_id, collection_time DESC)
94+
WITH
95+
(SORT_IN_TEMPDB = ON, DATA_COMPRESSION = PAGE' + @online_option + N');';
96+
EXEC sys.sp_executesql @index_sql;
97+
PRINT 'Created collect.query_store_data.IX_query_store_data_id_lookup index';
98+
END;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
01_add_query_lookup_indexes.sql

0 commit comments

Comments
 (0)