-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsp_StatUpdate_Diag.sql
More file actions
6151 lines (5754 loc) · 340 KB
/
sp_StatUpdate_Diag.sql
File metadata and controls
6151 lines (5754 loc) · 340 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
SET ANSI_NULLS ON;
GO
SET QUOTED_IDENTIFIER ON;
GO
/*
sp_StatUpdate_Diag - Diagnostic & Recommendation Engine
Copyright (c) 2026 Community Contribution
https://github.com/nanoDBA/sp_StatUpdate
Purpose: Analyze CommandLog data from sp_StatUpdate runs to detect problems,
identify suboptimal configurations, and recommend parameter changes.
Supports obfuscated output mode for safe external sharing.
Based on: sp_StatUpdate CommandLog format and Ola Hallengren's CommandLog table.
License: MIT License
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.
Version: 2026.04.17.1 (CalVer: YYYY.MM.DD; same-day patches append .1, .2, etc.)
History: 2026.04.17.1 - Diag bug batch Phase 2 (10 issues):
gh-471: Sync @procedure_version with header Version.
gh-472: Auto-upgrade StatUpdateDiagHistory via COL_LENGTH-gated ALTER.
gh-473: C3 denominator/numerator parity -- filter @time_limit_runs by
IsKilled=0 to match @total_completed_runs.
gh-474: Move @latest_qs_run DECLARE above IF/ELSE so SingleResultSet
path can reference it.
gh-475: W12 clamp StatsProcessed-MopUpProcessed to >=0 via CASE WHEN.
gh-476: W7 evidence filter uses @w7_median_position (ISNULL,0) instead
of hard-coded > 5.
gh-477: C5 evidence GROUP BY includes DatabaseName/SchemaName/ObjectName
to prevent collision across tables sharing a stat name.
gh-478: W6 clamp overhead_pct to [0,100] to avoid negative display
when stat_ms slightly exceeds wall-clock due to clock skew.
gh-479: C4 cross join requires recent_w.parallel_mode = prior_w.parallel_mode
to prevent cross-mode throughput comparisons.
gh-480: Dashboard SingleResultSet path -- initialize @avg_completion
and @tl_run_pct in @w3_parallel_complete=1 branch so downstream
COMPLETION detail text always has a value.
2026.04.13.2 - PARALLEL_COMPLETE parity and new checks (4 issues):
NaturalEndRuns (RS3): now counts PARALLEL_COMPLETE in addition to
COMPLETED so parallel runs are recognised as natural completions.
workload_pct (#qs_efficacy): PARALLEL_COMPLETE treated as 100% coverage
same as COMPLETED (all work distributed across workers finished).
W1d mop-up suggestion: fires for PARALLEL_COMPLETE runs in addition to
COMPLETED/NATURAL_END runs (parallel runs with time budget unused).
W12 PRIORITY_PASS_EMPTY: new WARNING when mop-up does all the work
(priority pass averages <5 stats across 3+ runs while mop-up runs).
SPEED grade parallel-aware: when all non-killed runs are parallel
(@StatsInParallel='Y'), grade is based on GB/min throughput instead
of sec/stat wall-clock (which underestimates per-worker speed).
2026.04.13.1 - Parallel-mode false positives (7 issues):
W3 STALE_BACKLOG: suppress when all non-killed runs are PARALLEL_COMPLETE
with AvgStatsRemaining=0 (per-worker remaining is not a real backlog).
W4 OVERLAPPING_RUNS: suppress when both overlapping runs have
StatsInParallel='Y' (intentional two-schedule parallel pattern).
COMPLETION grade: set score to 95 when all non-killed runs end with
PARALLEL_COMPLETE and AvgStatsRemaining=0 (aggregate = done).
C3 TIME_LIMIT_EXHAUSTION: also count COMPLETED runs with StatsRemaining>0
as functionally time-limited.
RS3 AvgDurationSec: exclude killed runs with StatsProcessed=0 (48-hour
phantom durations from orphan-cleanup synthetic END records).
I10 RECOMMENDED_CONFIG: removed stray @TieredThresholds and
@CleanupOrphanedRuns references from I10 code path (v2 params).
I11 FAILURE_CLUSTERING: escalate from INFO to WARNING when C2
REPEATED_FAILURES is also active and top error covers >80% of failures.
2026.03.26.1 - RS 13 memory grant and tempdb spill trending (#368): FirstMemoryGrantKB,
LastMemoryGrantKB, MemoryGrantChangePct, MemoryGrantTrend, FirstTempdbPages,
LastTempdbPages, TempdbChangePct, TempdbTrend columns. I8 headline includes
memory grant trends when data available. New #stat_updates columns:
QSTotalMemoryGrantKB, QSTotalTempdbPages (extracted from ExtendedInfo XML).
Cache table auto-upgrade for new columns.
2026.03.26 - W8 MOPUP_INEFFECTIVE: warn when mop-up consistently finds 0 stats (#347).
W9 LOCK_TIMEOUT_INEFFECTIVE: persistent lock timeout victims (#348).
W10 PARAMETER_CHURN: key parameters changing frequently (#350).
C5 SAMPLE_RATE_DEGRADATION: effective sample rate dropped >50% (#345).
I11 FAILURE_CLUSTERING: dominant error code detection (#346).
I12 QS_COVERAGE_DRIFT: QS plan count dropping >50% week-over-week (#349).
I13 PARALLEL_OPPORTUNITY: serial runs using <40% of TimeLimit (#351).
I14 MOPUP_MISSING_PAGECOUNT: throughput underestimation warning (#342).
I10 mutual exclusion validation guard (#343).
2026.03.24 - Mop-up awareness: #runs gets MopUpTriggered/MopUpFound/MopUpProcessed
from SP_STATUPDATE_END summary XML. RS4 Run Detail includes mop-up columns.
W1d recommendation: suggest @MopUpPass when runs complete early (<50% of
TimeLimit used). I10 RECOMMENDED_CONFIG includes @MopUpPass when W1d fires.
Fix: split #runs INSERT dynamic SQL to avoid nvarchar(4000) literal truncation.
- GB throughput metrics: TotalGB computed from per-stat PageCount aggregation.
RS3 Run Health Summary: AvgTotalGB, AvgGBPerMin.
RS4 Run Detail: TotalGB, GBPerMin per run.
RS10 Efficacy Trend: AvgGBPerMin per week.
C4 DEGRADING_THROUGHPUT: Evidence includes GB/min for both windows.
Dashboard SPEED: headlines include GB/min and GB/run volume context.
2026.03.23.1 - I10 RECOMMENDED_CONFIG: synthesized parameter set based on diagnostic
findings and parameter history. Preserves safeguards from prior runs,
adjusts flagged parameters, suggests untracked safeguards (@MinTempdbFreeMB,
@MaxConsecutiveFailures, @MaxAGRedoQueueMB).
2026.03.23 - NULL RunLabel fix for legacy CommandLog entries (ISNULL fallback).
- INSERT...EXEC safety: auto @SkipHistory=1, DB_ID guard, TRY/CATCH on map write.
- @ObfuscationMapTable dedup (NOT EXISTS prevents duplicate rows on repeated runs).
- Arithmetic overflow fix: nvarchar(10) widened to nvarchar(20) for decimal/ratio conversions.
- Unicode arrow (U+2192) replaced with ASCII '->'.
- Enhanced @Debug=1: timing at every stage, effective parameter echo, temp table row
counts and samples (#qs_efficacy, #executive_dashboard, #recommendations severity
breakdown), CommandLog date range, legacy label count, execution summary.
- Enhanced @Help=1: grading scale docs, prerequisites/limitations, grade override
examples, version history (9 result sets, was 5).
- Em dash removal (~60 occurrences) for PowerShell 5.1 compatibility.
2026.03.20 - 26-issue bulk resolution across 5 phases:
Phase 1: Cache watermark sentinel fix (#312), C4 parallelism-aware (#306).
Phase 2: ObjectId/StatsId extraction (#268), RS 2 Summary (#283),
RS 7 StatsInParallel (#287), RS 12 WorkloadRankPct/CumulativeCpuPct (#262),
RS 10 Top5ConcentrationPct (#269), cursor-to-set-based parsing (#302).
Phase 3: #workload_impact staging table, ImpactScore in RS 5/7 (#255),
WorkloadImpactPct in RS 5 (#261), IsVolatile flag (#259),
W7 HIGH_IMPACT_STATS_DEPRIORITIZED (#263), I9 WORKLOAD_CONCENTRATION (#264),
C3 workload impact enhancement (#265).
Phase 4: W2 CRITICAL escalation (#266), W5 partial read-only (#274),
W5/I6 capture mode guidance (#278), I8 improve-then-regress (#276),
W6 memory pressure guidance (#285), dashboard non-QS scoring (#267),
ExpertMode=0 workload context (#270).
Phase 5: AG secondary replica detection (#296), RS 3 ReplicaRole column,
15 new tests (162 -> 177).
2026.03.13 - Fix RS 12 WorkloadRank always=1 in SingleResultSet mode (#280).
- Fix RS 11 DeltaVsPrior missing minutes-to-critical delta in SingleResultSet mode (#282).
2026.03.11 - Fix empty-data RS 3 schema mismatch (#304): 7 columns -> 17 columns
matching production RS 3 (Run Health Summary).
2026.03.10.2 - RS 13 forced plan awareness (#292): ForcedPlanCount column, PlanTrend
distinguishes 'MORE PLANS (forced plan at risk)' from generic proliferation.
I8 recommendation warns when degrading stats have forced plans.
Cross-database sys.query_store_plan lookup (graceful on QS-disabled DBs).
2026.03.10.1 - @GradeOverrides and @GradeWeights parameters for Executive Dashboard.
Force grades (RELIABILITY=A), exclude categories (SPEED=IGNORE),
or change weights (COMPLETION=50, auto-normalized to 100%).
I6 QS_EFFICACY: graceful message when QS runs exist but no CPU data
(was emitting question marks).
2026.03.10 - DECLARE-in-loop fix for @map_table_msg variable.
RAISERROR decode query output when @ObfuscationMapTable is used.
2026.03.09.3 - W6 EXCESSIVE_OVERHEAD diagnostic check: flags runs where discovery/environment
overhead exceeds 40% of wall-clock time.
PAGE compression on StatUpdateDiagHistory PK and IX.
2026.03.09.2 - I8 silent failure: inserts INFO recommendation when no QS CPU data exists (#239).
@SingleResultSet=1 auto-promotes @ExpertMode=1 for stable ResultSetID contract (#236).
2026.03.09.1 - RunLabel dedup prevents PK violation on duplicate START entries (#216).
Watermark gap detection resets when CommandLog archived (#232).
I5 VERSION_HISTORY implemented (was documented but missing).
I8 QS_PERFORMANCE_TREND: per-stat per-execution CPU trend.
StatUpdateDiagCache persistent table avoids XML re-parse.
New @Help note for automation / @SingleResultSet.
2026.03.08.1 - Executive Dashboard (RS 1): letter grades A-F, health score 0-100,
5 categories (Overall, Completion, Reliability, Speed, Workload Focus).
@ExpertMode parameter: 0 = management view (2 RS), 1 = DBA deep-dive (13 RS).
Persistent history table (dbo.StatUpdateDiagHistory) with watermark-based
incremental inserts. @SkipHistory parameter to opt out.
QS Performance Correlation (I8 + RS 13): per-stat CPU trend detail.
QS Efficacy Trending (I6/I7 + RS 10/11/12): weekly aggregates,
per-run detail, high-CPU stat positions.
RS renumbered: RS 1 = Dashboard, RS 2 = Recommendations (always),
RS 3-13 = ExpertMode=1 only.
2026.03.06 - Security/correctness: SQL injection fix in @CommandLogDatabase,
missing AG keyword in @Help, parameter validation warnings.
12 bug fixes from SME review (BUG-01 through BUG-12).
@ObfuscationSeed and @ObfuscationMapTable parameters for
deterministic, reproducible obfuscation across runs.
@Help enhanced with valid_inputs, examples, operational notes.
2026.03.04.1 - Version format adopted CalVer (YYYY.MM.DD).
@SingleResultSet parameter: wraps all output into one table with
stable ResultSetID column for INSERT...EXEC automation.
Fix: empty-data path respects @SingleResultSet + @Obfuscate.
2026.02.12 - Initial release. 8 diagnostic checks (C1-C4, W1-W5, I1-I4),
obfuscation mode, @Help, @Debug.
9 result sets. 53 tests.
Requires: - dbo.CommandLog table (Ola Hallengren's SQL Server Maintenance Solution)
- sp_StatUpdate entries in CommandLog (SP_STATUPDATE_START/END + UPDATE_STATISTICS)
- SQL Server 2017+ (STRING_AGG; STRING_SPLIT is 2016+ but STRING_AGG requires 2017+)
Key Features:
- Executive Dashboard with A-F grades and health scores
- 16 diagnostic checks across CRITICAL/WARNING/INFO severities
- Obfuscation mode for safe external sharing (HASHBYTES-based)
- Persistent history for trend tracking (dbo.StatUpdateDiagHistory)
- Persistent stat cache for fast re-runs (dbo.StatUpdateDiagCache)
- Query Store efficacy analysis (proves QS prioritization value)
- @ExpertMode: management view (2 RS) vs DBA deep-dive (13 RS)
- @SingleResultSet for stable automation interface
Usage: -- Quick health check (management dashboard):
EXECUTE dbo.sp_StatUpdate_Diag;
-- Full DBA deep-dive:
EXECUTE dbo.sp_StatUpdate_Diag @ExpertMode = 1;
-- Obfuscated for sharing externally:
EXECUTE dbo.sp_StatUpdate_Diag @Obfuscate = 1;
-- Custom analysis window:
EXECUTE dbo.sp_StatUpdate_Diag @DaysBack = 90, @Debug = 1;
-- CommandLog in a different database:
EXECUTE dbo.sp_StatUpdate_Diag @CommandLogDatabase = N'DBATools';
*/
IF OBJECT_ID(N'dbo.sp_StatUpdate_Diag', N'P') IS NULL
BEGIN
EXECUTE (N'CREATE PROCEDURE dbo.sp_StatUpdate_Diag AS RETURN 138;');
END;
GO
ALTER PROCEDURE
dbo.sp_StatUpdate_Diag
(
@DaysBack integer = 30, /* history window in days */
@CommandLogDatabase sysname = NULL, /* NULL = current DB */
@Obfuscate bit = 0, /* 0 = real names, 1 = hashed names */
@ObfuscationSeed nvarchar(128) = NULL, /* salt for HASHBYTES -- makes tokens unpredictable without seed */
@ObfuscationMapTable sysname = NULL, /* persist obfuscation map to this table (auto-creates if missing) */
@LongRunningMinutes integer = 10, /* threshold for "long-running stat" detection */
@FailureThreshold integer = 3, /* same stat failing N+ times = CRITICAL */
@TimeLimitExhaustionPct integer = 80, /* warn if >X% of runs hit time limit */
@ThroughputWindowDays integer = 7, /* window size for throughput trend comparison */
@TopN integer = 20, /* top N items in detail result sets */
@EfficacyDaysBack integer = NULL, /* QS efficacy trending window (NULL = @DaysBack) */
@EfficacyDetailDays integer = NULL, /* QS efficacy close-up window (NULL = 14 or @EfficacyDaysBack) */
@ExpertMode bit = 0, /* 0 = Executive Dashboard + Recommendations (management view), 1 = all result sets (DBA view) */
@SkipHistory bit = 0, /* 1 = skip reading/writing dbo.StatUpdateDiagHistory (for testing or transient installs) */
@GradeOverrides nvarchar(500) = NULL, /* force grades or IGNORE categories: 'RELIABILITY=A, SPEED=IGNORE' */
@GradeWeights nvarchar(500) = NULL, /* custom category weights (auto-normalized): 'COMPLETION=40, WORKLOAD=40' */
@Help bit = 0,
@Debug bit = 0,
@SingleResultSet bit = 0, /* 0 = default multi-result-set, 1 = single result set with ResultSetID/RowData */
@Version varchar(20) = NULL OUTPUT,
@VersionDate datetime = NULL OUTPUT
)
WITH RECOMPILE
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
/*
============================================================================
VERSION AND CONSTANTS
============================================================================
*/
DECLARE
@procedure_version varchar(20) = '2026.04.17.1',
@procedure_version_date datetime = '20260417';
SET @Version = @procedure_version;
SET @VersionDate = @procedure_version_date;
/* Debug timing baseline */
DECLARE @debug_timer datetime2(3) = SYSDATETIME();
DECLARE @debug_elapsed_ms integer;
/*
============================================================================
HELP
============================================================================
*/
IF @Help = 1
BEGIN
/* Result set 1: Parameters */
SELECT
help_topic = N'Parameters',
parameter_name = parameter_name,
data_type = data_type,
description = description,
valid_inputs = valid_inputs,
defaults = default_value
FROM
(
VALUES
(N'@DaysBack', N'integer', N'History window in days -- how far back to scan CommandLog',
N'1-3650', N'30'),
(N'@CommandLogDatabase', N'sysname', N'Database containing dbo.CommandLog table. NULL = current database context.',
N'NULL, database name (e.g., DBATools, master)', N'NULL (current database)'),
(N'@Obfuscate', N'bit', N'Hash database/schema/table/stat names for safe external sharing. Uses HASHBYTES MD5. Obfuscation map returned as result set 8 (multi-result-set mode only).',
N'0, 1', N'0'),
(N'@ObfuscationSeed', N'nvarchar(128)', N'Salt prepended to names before hashing. Makes tokens unpredictable without the seed but deterministic across runs/servers with the same seed. NULL = unsalted (backward compatible).',
N'NULL, any string up to 128 chars', N'NULL'),
(N'@ObfuscationMapTable', N'sysname', N'Persist obfuscation map to this table (auto-creates if missing, merges new entries on subsequent runs). Enables saving the map on prod while exporting only obfuscated results. Requires @Obfuscate=1.',
N'NULL, table name (e.g., dbo.DiagMap, tempdb.dbo.diag_map)', N'NULL'),
(N'@LongRunningMinutes', N'integer', N'Stats taking longer than this (in minutes) are flagged in W2 check and Long-Running Statistics result set',
N'1-N minutes', N'10'),
(N'@FailureThreshold', N'integer', N'Same statistic failing this many times across runs triggers C2 CRITICAL finding',
N'1-N', N'3'),
(N'@TimeLimitExhaustionPct', N'integer', N'If more than this percentage of runs hit TIME_LIMIT stop reason, triggers C3 CRITICAL finding',
N'1-100', N'80'),
(N'@ThroughputWindowDays', N'integer', N'Compare recent N days throughput against prior window for C4 degrading throughput detection',
N'1-@DaysBack', N'7'),
(N'@TopN', N'integer', N'Maximum rows returned in detail result sets (Top Tables, Failing Stats, Long-Running Stats)',
N'1-1000', N'20'),
(N'@EfficacyDaysBack', N'integer', N'Broad window for QS efficacy trending (weekly aggregates). NULL inherits from @DaysBack.',
N'NULL, 1-3650', N'NULL (= @DaysBack)'),
(N'@EfficacyDetailDays', N'integer', N'Close-up window for run-over-run efficacy detail. NULL defaults to 14 or @EfficacyDaysBack if smaller.',
N'NULL, 1-@EfficacyDaysBack', N'NULL (= min(14, @EfficacyDaysBack))'),
(N'@ExpertMode', N'bit', N'0 = Executive Dashboard + Recommendations only (management-friendly, show this to leadership). 1 = All result sets including technical detail (DBA deep-dive). Inspired by sp_Blitz @ExpertMode.',
N'0, 1', N'0'),
(N'@SkipHistory', N'bit', N'1 = Skip reading/writing dbo.StatUpdateDiagHistory persistent table. Useful for transient installs, testing, or when you do not want permanent tables created.',
N'0, 1', N'0'),
(N'@GradeOverrides', N'nvarchar(500)', N'Force dashboard grades or ignore categories. Comma-separated CATEGORY=VALUE pairs. Categories: COMPLETION, RELIABILITY, SPEED, WORKLOAD. Values: A/B/C/D/F (force grade) or IGNORE (exclude from OVERALL). Example: ''RELIABILITY=A, SPEED=IGNORE'' -- forces Reliability to A, excludes Speed from the overall score.',
N'NULL, comma-separated pairs (e.g., ''RELIABILITY=A'', ''SPEED=IGNORE, WORKLOAD=B'')', N'NULL'),
(N'@GradeWeights', N'nvarchar(500)', N'Custom category weights for OVERALL score. Comma-separated CATEGORY=WEIGHT pairs. Integers, auto-normalized to sum to 100%. Omitted categories keep default weight. Weight of 0 = same as IGNORE. Defaults: COMPLETION=30, RELIABILITY=25, SPEED=20, WORKLOAD=25.',
N'NULL, comma-separated pairs (e.g., ''COMPLETION=50'', ''COMPLETION=40, WORKLOAD=40, SPEED=20'')', N'NULL'),
(N'@Help', N'bit', N'Show this help output and return immediately',
N'0, 1', N'0'),
(N'@Debug', N'bit', N'Verbose diagnostic output -- shows intermediate temp table counts and timing',
N'0, 1', N'0'),
(N'@SingleResultSet', N'bit', N'Collapse all result sets into one with columns (ResultSetID, ResultSetName, RowNum, RowData). RowData is JSON. Enables INSERT...EXEC capture in automation. Auto-promotes @ExpertMode=1 so ResultSetIDs 1-13 are always present.',
N'0, 1', N'0'),
(N'@Version', N'varchar(20)', N'OUTPUT: returns procedure version string (e.g., 2026.03.04)',
N'OUTPUT', N'OUTPUT'),
(N'@VersionDate', N'datetime', N'OUTPUT: returns procedure version date',
N'OUTPUT', N'OUTPUT')
) AS v (parameter_name, data_type, description, valid_inputs, default_value);
/* Result set 2: Diagnostic checks */
SELECT
help_topic = N'Diagnostic Checks',
check_id = check_id,
severity = severity,
category = category,
description = description
FROM
(
VALUES
(N'C1', N'CRITICAL', N'KILLED_RUNS', N'START without matching END (orphaned/killed runs)'),
(N'C2', N'CRITICAL', N'REPEATED_FAILURES', N'Same statistic failing consistently across runs'),
(N'C3', N'CRITICAL', N'TIME_LIMIT_EXHAUSTION', N'Majority of runs hitting TIME_LIMIT with stats remaining'),
(N'C4', N'CRITICAL', N'DEGRADING_THROUGHPUT', N'Average seconds-per-stat increasing over time'),
(N'W1', N'WARNING', N'SUBOPTIMAL_PARAMS', N'Parameter choices that may reduce effectiveness'),
(N'W2', N'WARNING', N'LONG_RUNNING_STATS', N'Individual stats consistently exceeding threshold'),
(N'W3', N'WARNING', N'STALE_BACKLOG', N'Persistent backlog of unprocessed qualifying stats'),
(N'W4', N'WARNING', N'OVERLAPPING_RUNS', N'Multiple runs active simultaneously'),
(N'W5', N'WARNING', N'QS_NOT_EFFECTIVE', N'Query Store priority enabled but no QS data captured'),
(N'W6', N'WARNING', N'EXCESSIVE_OVERHEAD', N'Discovery/environment checks consuming disproportionate time vs actual UPDATE STATISTICS'),
(N'I1', N'INFO', N'RUN_HEALTH', N'Completion rate, duration trend, StopReason distribution'),
(N'I2', N'INFO', N'PARAMETER_HISTORY', N'How parameters changed across runs'),
(N'I3', N'INFO', N'TOP_TABLES', N'Tables consuming the most maintenance time'),
(N'I4', N'INFO', N'UNUSED_FEATURES', N'Available features not being used'),
(N'I5', N'INFO/WARNING', N'VERSION_HISTORY', N'sp_StatUpdate versions used across analysis window. WARNING if multiple versions detected (version skew).'),
(N'I6', N'INFO', N'QS_EFFICACY', N'Query Store prioritization effectiveness -- what % of high-workload stats get serviced early'),
(N'I7', N'INFO', N'QS_INFLECTION', N'Before/after comparison when sort order changed to Query Store-based prioritization'),
(N'I8', N'INFO', N'QS_PERFORMANCE_TREND', N'Per-stat per-execution query CPU trend -- are queries getting faster after stat updates? Detects forced plans at risk (#292).'),
(N'I10', N'INFO', N'RECOMMENDED_CONFIG', N'Synthesized parameter set balancing immediate fixes, long-term safeguards, and historical parameter usage. Based on diagnostic findings and parameter change history.'),
(N'W11', N'WARNING', N'MOPUP_LOW_YIELD', N'Mop-up pass consistently unable to process most of the stats it discovers (avg <50% yield across 3+ runs).'),
(N'W12', N'WARNING', N'PRIORITY_PASS_EMPTY', N'Priority pass averages <5 stats across 3+ mop-up runs -- mop-up is doing all the work. Lower @ModificationThreshold so the priority pass captures more stats.'),
(N'I15', N'INFO', N'HEAP_TIME_BUDGET', N'Heap tables account for >50% of total maintenance time. @CollectHeapForwarding and heap REBUILD may help.')
) AS v (check_id, severity, category, description);
/* Result set 3: Result set order */
SELECT
help_topic = N'Result Sets',
result_set_number = rs_num,
name = rs_name,
description = rs_desc
FROM
(
VALUES
(1, N'Executive Dashboard', N'ALWAYS returned first. Letter-graded categories (A-F) with plain English headlines for management. One row per category: Overall, Completion, Reliability, Speed, Workload Coverage. Inspired by sp_Blitz priority system.'),
(2, N'Recommendations', N'Severity-categorized findings with parameter suggestions. Returned with @ExpertMode=0 (management view).'),
(3, N'Run Health Summary', N'Aggregate metrics across all runs. Requires @ExpertMode=1.'),
(4, N'Run Detail', N'Per-run metrics. Requires @ExpertMode=1.'),
(5, N'Top Tables', N'Top N tables by total update duration. Requires @ExpertMode=1.'),
(6, N'Failing Statistics', N'Stats with errors, grouped. Requires @ExpertMode=1.'),
(7, N'Long-Running Statistics', N'Stats exceeding threshold. Requires @ExpertMode=1.'),
(8, N'Parameter Change History', N'Parameter values across runs. Requires @ExpertMode=1.'),
(9, N'Obfuscation Map (conditional)', N'Only when @Obfuscate=1. Requires @ExpertMode=1.'),
(10, N'Efficacy Trend (Weekly)', N'Weekly QS efficacy metrics: high-workload coverage, completion %, throughput trend. Requires @ExpertMode=1.'),
(11, N'Efficacy Detail (Per-Run)', N'Per-run QS efficacy for close-up window (@EfficacyDetailDays). Requires @ExpertMode=1.'),
(12, N'High-CPU Stat Positions', N'Top-workload stats from most recent run with their processing positions. Requires @ExpertMode=1.'),
(13, N'QS Performance Correlation', N'Per-stat Query Store CPU, memory grant, and tempdb trend across runs. Shows whether queries get faster and use less memory after stat updates. Requires @ExpertMode=1.'),
(0, N'Unified Result Set', N'When @SingleResultSet=1: one result set with ResultSetID, ResultSetName, RowNum, RowData (JSON). Replaces all result sets.')
) AS v (rs_num, rs_name, rs_desc);
/* Result set 4: Examples */
SELECT
help_topic = N'Examples',
example_name = example_name,
example_description = example_description,
example_code = example_code
FROM
(
VALUES
(
N'Quick Health Check',
N'Run with defaults against current database CommandLog',
N'EXECUTE dbo.sp_StatUpdate_Diag;'
),
(
N'Obfuscated for Sharing',
N'Hash all object names for safe sharing with consultants or support',
N'EXECUTE dbo.sp_StatUpdate_Diag @Obfuscate = 1;'
),
(
N'Extended History',
N'Analyze 90 days of history with debug output',
N'EXECUTE dbo.sp_StatUpdate_Diag @DaysBack = 90, @Debug = 1;'
),
(
N'Remote CommandLog',
N'CommandLog lives in a central DBA database',
N'EXECUTE dbo.sp_StatUpdate_Diag @CommandLogDatabase = N''DBATools'';'
),
(
N'Strict Failure Detection',
N'Flag stats failing 2+ times, long-running at 5 min',
N'EXECUTE dbo.sp_StatUpdate_Diag @FailureThreshold = 2, @LongRunningMinutes = 5;'
),
(
N'Automation Capture',
N'Single result set for INSERT...EXEC or PowerShell capture',
N'EXECUTE dbo.sp_StatUpdate_Diag @SingleResultSet = 1;'
),
(
N'Secure Obfuscated Export',
N'Save map on prod, export only obfuscated results for external analysis',
N'EXECUTE dbo.sp_StatUpdate_Diag @Obfuscate = 1, @SingleResultSet = 1, @ObfuscationSeed = N''MySecretSeed'', @ObfuscationMapTable = N''dbo.DiagObfuscationMap'';'
),
(
N'QS Efficacy Report',
N'Show 100-day QS prioritization effectiveness with 14-day close-up',
N'EXECUTE dbo.sp_StatUpdate_Diag @EfficacyDaysBack = 100, @EfficacyDetailDays = 14;'
),
(
N'Multi-Server (PowerShell)',
N'Use Invoke-StatUpdateDiag.ps1 for cross-server analysis',
N'.\Invoke-StatUpdateDiag.ps1 -Servers "Server1","Server2" -OutputFormat Markdown -OutputPath C:\Reports'
)
) AS example_data (example_name, example_description, example_code);
/* Result set 5: Operational Notes */
SELECT
help_topic = N'Operational Notes',
topic = topic,
detail = detail
FROM
(
VALUES
(N'Obfuscation',
N'@Obfuscate=1 replaces names with MD5 hashes (e.g., DB_a1b2c3, TBL_d4e5f6). Prefixes preserved for readability. Map is result set 8 in multi-result-set mode (excluded from @SingleResultSet=1 to prevent leaking real names). Use @ObfuscationMapTable to persist the map on prod. Use @ObfuscationSeed to salt hashes -- makes tokens stable across servers with the same seed but unpredictable without it.'),
(N'Killed Run Detection',
N'Two detection methods: (1) SP_STATUPDATE_START without matching SP_STATUPDATE_END = orphaned run, (2) SP_STATUPDATE_END with StopReason=KILLED = cleaned up by @CleanupOrphanedRuns. Both trigger C1 CRITICAL.'),
(N'Throughput Trend (C4)',
N'Compares average seconds-per-stat in the recent @ThroughputWindowDays against the prior window of equal length. If recent average is >50% worse, triggers C4 CRITICAL. Data-dependent -- requires sufficient runs in both windows.'),
(N'Overlapping Runs (W4)',
N'Detects concurrent sp_StatUpdate executions by checking for overlapping StartTime/EndTime ranges. Excludes killed runs to prevent false positives from orphan-cleanup END records.'),
(N'SingleResultSet Mode',
N'@SingleResultSet=1 wraps all 8 result sets into one table with columns: ResultSetID (int), ResultSetName (nvarchar), RowNum (int), RowData (nvarchar(max) as JSON). Use OPENJSON(RowData) to parse. Enables INSERT...EXEC patterns that fail with multiple result sets.'),
(N'CommandLog Requirements',
N'Requires Ola Hallengren''s dbo.CommandLog table with sp_StatUpdate entries (CommandType IN SP_STATUPDATE_START, SP_STATUPDATE_END, UPDATE_STATISTICS). No data = no diagnostics (graceful empty result sets). @CommandLogDatabase lets you point to a central logging database.'),
(N'PowerShell Wrapper',
N'Invoke-StatUpdateDiag.ps1 runs sp_StatUpdate_Diag across multiple servers in parallel, detects version skew and parameter inconsistencies, and generates Markdown/HTML/JSON reports. Use -Obfuscate for safe sharing.'),
(N'Automation / Stable Result Sets',
N'The number of result sets varies by @ExpertMode (2 vs 12-13) and @Obfuscate (shifts RS9). For automation, use @SingleResultSet=1 -- it wraps all output into one table with a stable ResultSetID column (values 1-13). This makes INSERT...EXEC reliable regardless of parameter combinations.')
) AS notes (topic, detail);
/* Result set 6: Grading Scale */
SELECT
help_topic = N'Grading Scale',
grade = grade,
score_range = score_range,
meaning = meaning
FROM
(
VALUES
(N'A', N'90-100', N'Excellent -- statistics maintenance is healthy and effective'),
(N'B', N'75-89', N'Good -- minor improvements possible but no urgent issues'),
(N'C', N'60-74', N'Fair -- noticeable gaps or inefficiencies that deserve attention'),
(N'D', N'40-59', N'Poor -- significant problems impacting maintenance quality'),
(N'F', N'0-39', N'Failing -- critical issues requiring immediate action')
) AS v (grade, score_range, meaning);
SELECT
help_topic = N'Grading Scale',
category = category,
default_weight = default_weight,
what_it_measures = what_it_measures
FROM
(
VALUES
(N'COMPLETION', N'30%', N'What percentage of qualifying stats get updated each run'),
(N'RELIABILITY', N'25%', N'Absence of killed runs, failures, and orphaned entries'),
(N'SPEED', N'20%', N'Average seconds per stat update -- are updates fast enough'),
(N'WORKLOAD', N'25%', N'Are high-CPU stats prioritized early (requires @QueryStore = CPU in v3, @QueryStorePriority=Y in v2)')
) AS v (category, default_weight, what_it_measures);
/* Result set 7: Prerequisites and Known Limitations */
SELECT
help_topic = N'Prerequisites and Limitations',
topic = topic,
detail = detail
FROM
(
VALUES
(N'SQL Server Version',
N'Requires SQL Server 2016+ (STRING_SPLIT dependency). Best on 2017+ for STRING_AGG.'),
(N'CommandLog Table',
N'Requires dbo.CommandLog from Ola Hallengren''s Maintenance Solution. sp_StatUpdate writes SP_STATUPDATE_START, SP_STATUPDATE_END, and UPDATE_STATISTICS entries. No CommandLog = no diagnostics.'),
(N'sp_StatUpdate Version',
N'Full feature support requires sp_StatUpdate v2.16+ (ProcessingPosition in ExtendedInfo for RS 12). QS efficacy requires QS-enabled runs (@QueryStore in v3, @QueryStorePriority=Y in v2). Legacy runs (pre-RunLabel) are supported with synthetic labels.'),
(N'INSERT...EXEC Limitation',
N'@SingleResultSet=1 auto-enables @SkipHistory=1 because SQL Server''s INSERT...EXEC creates an implicit transaction that prevents persistent table writes. @ObfuscationMapTable writes are attempted but may silently fail in this context.'),
(N'Obfuscation Map Security',
N'@SingleResultSet=1 excludes the obfuscation map from RowData to prevent leaking real names in the unified output. Use @ObfuscationMapTable to persist the map separately.'),
(N'WORKLOAD Grade Without QS',
N'If no runs use QS prioritization (@QueryStore in v3, @QueryStorePriority=Y in v2), the WORKLOAD category scores a neutral 50/100 (grade C). Enable QS prioritization for meaningful workload grades.')
) AS v (topic, detail);
/* Result set 8: Additional Examples */
SELECT
help_topic = N'Grade Override Examples',
example_name = example_name,
example_description = example_description,
example_code = example_code
FROM
(
VALUES
(
N'Override Reliability Grade',
N'Force Reliability to A (you know the killed runs are from planned maintenance)',
N'EXECUTE dbo.sp_StatUpdate_Diag @GradeOverrides = N''RELIABILITY=A'';'
),
(
N'Ignore Workload (No QS)',
N'Exclude Workload from Overall score when QS is not used',
N'EXECUTE dbo.sp_StatUpdate_Diag @GradeOverrides = N''WORKLOAD=IGNORE'';'
),
(
N'Custom Weights',
N'Weight Completion and Reliability heavily, reduce Speed importance',
N'EXECUTE dbo.sp_StatUpdate_Diag @GradeWeights = N''COMPLETION=40, RELIABILITY=35, SPEED=5, WORKLOAD=20'';'
),
(
N'Combined Override + Weights',
N'Force Reliability=A and reweight remaining categories',
N'EXECUTE dbo.sp_StatUpdate_Diag @GradeOverrides = N''RELIABILITY=A'', @GradeWeights = N''COMPLETION=50, SPEED=25, WORKLOAD=25'';'
)
) AS v (example_name, example_description, example_code);
/* Result set 9: Version History */
SELECT
help_topic = N'Version History',
version_date = version_date,
changes = changes
FROM
(
VALUES
(N'2026.03.23', N'NULL RunLabel fix, INSERT...EXEC safety, ObfuscationMapTable dedup, nvarchar overflow fixes, enhanced debug output, improved @Help'),
(N'2026.03.20', N'26-issue bulk resolution: workload impact, check improvements, AG detection, 15 new tests'),
(N'2026.03.13', N'RS 12 WorkloadRank fix, RS 11 DeltaVsPrior fix in SingleResultSet mode'),
(N'2026.03.11', N'Empty-data RS 3 schema mismatch fix'),
(N'2026.03.09', N'RunLabel dedup, QS efficacy, executive dashboard, persistent history')
) AS v (version_date, changes);
RETURN;
END;
/*
============================================================================
RUNTIME VERSION GUARD (BUG-02: STRING_AGG requires SQL Server 2017+)
============================================================================
*/
IF CAST(SERVERPROPERTY('ProductMajorVersion') AS integer) < 14
BEGIN
RAISERROR(N'sp_StatUpdate_Diag requires SQL Server 2017 or later (STRING_AGG).', 16, 1);
RETURN;
END;
/* #296: AG secondary replica detection -- warn that CommandLog may reflect primary-side maintenance only */
DECLARE @ag_replica_role nvarchar(20) = NULL;
BEGIN TRY
SELECT TOP (1)
@ag_replica_role = CASE ars.role
WHEN 1 THEN N'PRIMARY'
WHEN 2 THEN N'SECONDARY'
ELSE N'UNKNOWN'
END
FROM sys.dm_hadr_availability_replica_states AS ars
WHERE ars.is_local = 1
ORDER BY ars.role;
IF @ag_replica_role = N'SECONDARY'
RAISERROR(N'WARNING: Running on AG secondary replica -- CommandLog data may reflect primary-side maintenance only.', 10, 1) WITH NOWAIT;
END TRY
BEGIN CATCH
/* Non-AG instances don''t have the DMV -- silently ignore */
SET @ag_replica_role = NULL;
END CATCH;
/*
============================================================================
PARAMETER VALIDATION
============================================================================
*/
DECLARE @errors nvarchar(max) = N'';
IF @DaysBack < 1 OR @DaysBack > 3650
SET @errors = @errors + N'@DaysBack must be between 1 and 3650. ';
IF @LongRunningMinutes < 1
SET @errors = @errors + N'@LongRunningMinutes must be >= 1. ';
IF @FailureThreshold < 1
SET @errors = @errors + N'@FailureThreshold must be >= 1. ';
IF @TimeLimitExhaustionPct < 1 OR @TimeLimitExhaustionPct > 100
SET @errors = @errors + N'@TimeLimitExhaustionPct must be between 1 and 100. ';
IF @ThroughputWindowDays < 1 OR @ThroughputWindowDays > @DaysBack
SET @errors = @errors + N'@ThroughputWindowDays must be between 1 and @DaysBack. ';
IF @TopN < 1 OR @TopN > 1000
SET @errors = @errors + N'@TopN must be between 1 and 1000. ';
/* Default @EfficacyDaysBack from @DaysBack; @EfficacyDetailDays from min(14, efficacy window) */
SET @EfficacyDaysBack = ISNULL(@EfficacyDaysBack, @DaysBack);
SET @EfficacyDetailDays = ISNULL(@EfficacyDetailDays, CASE WHEN @EfficacyDaysBack < 14 THEN @EfficacyDaysBack ELSE 14 END);
IF @EfficacyDaysBack < 1 OR @EfficacyDaysBack > 3650
SET @errors = @errors + N'@EfficacyDaysBack must be between 1 and 3650. ';
IF @EfficacyDetailDays < 1 OR @EfficacyDetailDays > @EfficacyDaysBack
SET @errors = @errors + N'@EfficacyDetailDays must be between 1 and @EfficacyDaysBack. ';
IF @Obfuscate = 0 AND @ObfuscationMapTable IS NOT NULL
RAISERROR(N'WARNING: @ObfuscationMapTable is ignored when @Obfuscate = 0.', 10, 1) WITH NOWAIT;
IF @Obfuscate = 0 AND @ObfuscationSeed IS NOT NULL
RAISERROR(N'WARNING: @ObfuscationSeed is ignored when @Obfuscate = 0.', 10, 1) WITH NOWAIT;
/* ---- Grade Override / Weight parsing ---- */
DECLARE
@override_completion char(1) = NULL, /* NULL=computed, A-F=forced, X=IGNORE */
@override_reliability char(1) = NULL,
@override_speed char(1) = NULL,
@override_workload char(1) = NULL,
@weight_completion decimal(5, 2) = 30.0,
@weight_reliability decimal(5, 2) = 25.0,
@weight_speed decimal(5, 2) = 20.0,
@weight_workload decimal(5, 2) = 25.0,
@has_overrides bit = 0;
IF @GradeOverrides IS NOT NULL
BEGIN
SET @has_overrides = 1;
/* #302: Set-based parsing (replaces cursor) */
DECLARE @go_parsed TABLE (pair nvarchar(100), cat nvarchar(50), val nvarchar(50));
INSERT INTO @go_parsed (pair, cat, val)
SELECT
pair = LTRIM(RTRIM(s.value)),
cat = UPPER(LTRIM(RTRIM(LEFT(LTRIM(RTRIM(s.value)), CHARINDEX(N'=', LTRIM(RTRIM(s.value))) - 1)))),
val = UPPER(LTRIM(RTRIM(SUBSTRING(LTRIM(RTRIM(s.value)), CHARINDEX(N'=', LTRIM(RTRIM(s.value))) + 1, 50))))
FROM STRING_SPLIT(@GradeOverrides, N',') AS s
WHERE LTRIM(RTRIM(s.value)) <> N''
AND CHARINDEX(N'=', LTRIM(RTRIM(s.value))) > 0;
/* Collect errors for malformed pairs (no '=') */
SELECT @errors = @errors + N'@GradeOverrides: invalid pair ''' + LTRIM(RTRIM(s.value)) + N''' (expected CATEGORY=VALUE). '
FROM STRING_SPLIT(@GradeOverrides, N',') AS s
WHERE LTRIM(RTRIM(s.value)) <> N''
AND CHARINDEX(N'=', LTRIM(RTRIM(s.value))) = 0;
/* Unknown categories */
SELECT @errors = @errors + N'@GradeOverrides: unknown category ''' + p.cat + N'''. Valid: COMPLETION, RELIABILITY, SPEED, WORKLOAD. '
FROM @go_parsed AS p
WHERE p.cat NOT IN (N'COMPLETION', N'RELIABILITY', N'SPEED', N'WORKLOAD');
/* Invalid values */
SELECT @errors = @errors + N'@GradeOverrides: invalid value ''' + p.val + N''' for ' + p.cat + N'. Valid: A, B, C, D, F, IGNORE. '
FROM @go_parsed AS p
WHERE p.cat IN (N'COMPLETION', N'RELIABILITY', N'SPEED', N'WORKLOAD')
AND p.val NOT IN (N'A', N'B', N'C', N'D', N'F', N'IGNORE');
/* Apply valid overrides (ISNULL preserves defaults for unmentioned categories) */
SELECT
@override_completion = ISNULL(MAX(CASE WHEN p.cat = N'COMPLETION' THEN CASE WHEN p.val = N'IGNORE' THEN 'X' ELSE LEFT(p.val, 1) END END), @override_completion),
@override_reliability = ISNULL(MAX(CASE WHEN p.cat = N'RELIABILITY' THEN CASE WHEN p.val = N'IGNORE' THEN 'X' ELSE LEFT(p.val, 1) END END), @override_reliability),
@override_speed = ISNULL(MAX(CASE WHEN p.cat = N'SPEED' THEN CASE WHEN p.val = N'IGNORE' THEN 'X' ELSE LEFT(p.val, 1) END END), @override_speed),
@override_workload = ISNULL(MAX(CASE WHEN p.cat = N'WORKLOAD' THEN CASE WHEN p.val = N'IGNORE' THEN 'X' ELSE LEFT(p.val, 1) END END), @override_workload)
FROM @go_parsed AS p
WHERE p.cat IN (N'COMPLETION', N'RELIABILITY', N'SPEED', N'WORKLOAD')
AND p.val IN (N'A', N'B', N'C', N'D', N'F', N'IGNORE');
END;
IF @GradeWeights IS NOT NULL
BEGIN
SET @has_overrides = 1;
/* #302: Set-based parsing (replaces cursor) */
DECLARE @gw_parsed TABLE (pair nvarchar(100), cat nvarchar(50), val_str nvarchar(50));
INSERT INTO @gw_parsed (pair, cat, val_str)
SELECT
pair = LTRIM(RTRIM(s.value)),
cat = UPPER(LTRIM(RTRIM(LEFT(LTRIM(RTRIM(s.value)), CHARINDEX(N'=', LTRIM(RTRIM(s.value))) - 1)))),
val_str = LTRIM(RTRIM(SUBSTRING(LTRIM(RTRIM(s.value)), CHARINDEX(N'=', LTRIM(RTRIM(s.value))) + 1, 50)))
FROM STRING_SPLIT(@GradeWeights, N',') AS s
WHERE LTRIM(RTRIM(s.value)) <> N''
AND CHARINDEX(N'=', LTRIM(RTRIM(s.value))) > 0;
/* Malformed pairs (no '=') */
SELECT @errors = @errors + N'@GradeWeights: invalid pair ''' + LTRIM(RTRIM(s.value)) + N''' (expected CATEGORY=WEIGHT). '
FROM STRING_SPLIT(@GradeWeights, N',') AS s
WHERE LTRIM(RTRIM(s.value)) <> N''
AND CHARINDEX(N'=', LTRIM(RTRIM(s.value))) = 0;
/* Unknown categories */
SELECT @errors = @errors + N'@GradeWeights: unknown category ''' + p.cat + N'''. Valid: COMPLETION, RELIABILITY, SPEED, WORKLOAD. '
FROM @gw_parsed AS p
WHERE p.cat NOT IN (N'COMPLETION', N'RELIABILITY', N'SPEED', N'WORKLOAD');
/* Invalid weights */
SELECT @errors = @errors + N'@GradeWeights: invalid weight ''' + p.val_str + N''' for ' + p.cat + N'. Must be a non-negative integer. '
FROM @gw_parsed AS p
WHERE p.cat IN (N'COMPLETION', N'RELIABILITY', N'SPEED', N'WORKLOAD')
AND (TRY_CONVERT(integer, p.val_str) IS NULL OR TRY_CONVERT(integer, p.val_str) < 0);
/* Apply valid weights (ISNULL preserves defaults for unmentioned categories) */
SELECT
@weight_completion = ISNULL(MAX(CASE WHEN p.cat = N'COMPLETION' THEN CONVERT(decimal(5, 2), CONVERT(integer, p.val_str)) END), @weight_completion),
@weight_reliability = ISNULL(MAX(CASE WHEN p.cat = N'RELIABILITY' THEN CONVERT(decimal(5, 2), CONVERT(integer, p.val_str)) END), @weight_reliability),
@weight_speed = ISNULL(MAX(CASE WHEN p.cat = N'SPEED' THEN CONVERT(decimal(5, 2), CONVERT(integer, p.val_str)) END), @weight_speed),
@weight_workload = ISNULL(MAX(CASE WHEN p.cat = N'WORKLOAD' THEN CONVERT(decimal(5, 2), CONVERT(integer, p.val_str)) END), @weight_workload)
FROM @gw_parsed AS p
WHERE p.cat IN (N'COMPLETION', N'RELIABILITY', N'SPEED', N'WORKLOAD')
AND TRY_CONVERT(integer, p.val_str) IS NOT NULL
AND TRY_CONVERT(integer, p.val_str) >= 0;
END;
/* Apply IGNORE -> weight=0, weight=0 -> IGNORE (they are equivalent) */
IF @override_completion = 'X' SET @weight_completion = 0;
IF @override_reliability = 'X' SET @weight_reliability = 0;
IF @override_speed = 'X' SET @weight_speed = 0;
IF @override_workload = 'X' SET @weight_workload = 0;
IF @weight_completion = 0 AND @override_completion IS NULL SET @override_completion = 'X';
IF @weight_reliability = 0 AND @override_reliability IS NULL SET @override_reliability = 'X';
IF @weight_speed = 0 AND @override_speed IS NULL SET @override_speed = 'X';
IF @weight_workload = 0 AND @override_workload IS NULL SET @override_workload = 'X';
/* Normalize weights to sum to 1.0 */
DECLARE @weight_sum decimal(10, 2) = @weight_completion + @weight_reliability + @weight_speed + @weight_workload;
IF @has_overrides = 1 AND @weight_sum = 0
SET @errors = @errors + N'All categories are IGNOREd or have weight 0 -- cannot compute OVERALL score. ';
IF @weight_sum > 0
BEGIN
SET @weight_completion = @weight_completion / @weight_sum;
SET @weight_reliability = @weight_reliability / @weight_sum;
SET @weight_speed = @weight_speed / @weight_sum;
SET @weight_workload = @weight_workload / @weight_sum;
END;
IF @errors <> N''
BEGIN
RAISERROR(N'Parameter validation failed: %s', 16, 1, @errors) WITH NOWAIT;
RETURN;
END;
/*
============================================================================
VALIDATE COMMANDLOG EXISTS
============================================================================
*/
DECLARE
@commandlog_ref nvarchar(500),
@commandlog_db sysname = ISNULL(@CommandLogDatabase, DB_NAME()),
@sql nvarchar(max),
@commandlog_exists bit = 0;
SET @commandlog_ref = QUOTENAME(@commandlog_db) + N'.dbo.CommandLog';
SET @sql = N'
IF OBJECT_ID(N''' + REPLACE(@commandlog_ref, N'''', N'''''') + N''', N''U'') IS NOT NULL
SET @exists = 1;
ELSE
SET @exists = 0;
';
EXECUTE sys.sp_executesql
@sql,
N'@exists bit OUTPUT',
@exists = @commandlog_exists OUTPUT;
IF @commandlog_exists = 0
BEGIN
RAISERROR(N'CommandLog table not found at %s. Verify @CommandLogDatabase parameter.', 16, 1, @commandlog_ref) WITH NOWAIT;
RETURN;
END;
DECLARE @expert_int integer = CONVERT(integer, @ExpertMode);
/* #236: @SingleResultSet=1 auto-promotes @ExpertMode=1 so automation always gets RS 1-13.
Also forces @SkipHistory=1 because INSERT...EXEC callers cannot write to persistent tables
(SQL Server nested transaction limitation). */
IF @SingleResultSet = 1
BEGIN
IF @ExpertMode = 0
BEGIN
SET @ExpertMode = 1;
SET @expert_int = 1;
RAISERROR(N'Note: @SingleResultSet=1 auto-enabled @ExpertMode=1 for stable ResultSetID contract (1-13).', 10, 1) WITH NOWAIT;
END;
IF @SkipHistory = 0
BEGIN
SET @SkipHistory = 1;
RAISERROR(N'Note: @SingleResultSet=1 auto-enabled @SkipHistory=1 (INSERT...EXEC cannot write persistent tables).', 10, 1) WITH NOWAIT;
END;
END;
RAISERROR(N'sp_StatUpdate_Diag v%s', 10, 1, @procedure_version) WITH NOWAIT;
RAISERROR(N'CommandLog: %s', 10, 1, @commandlog_ref) WITH NOWAIT;
RAISERROR(N'Analysis window: %i days', 10, 1, @DaysBack) WITH NOWAIT;
DECLARE @obfuscate_int integer = CONVERT(integer, @Obfuscate);
RAISERROR(N'Obfuscate: %i', 10, 1, @obfuscate_int) WITH NOWAIT;
RAISERROR(N'ExpertMode: %i', 10, 1, @expert_int) WITH NOWAIT;
IF @has_overrides = 1
BEGIN
DECLARE @override_msg nvarchar(500) = N'Grade overrides active:';
IF @override_completion IS NOT NULL SET @override_msg = @override_msg + N' COMPLETION=' + CASE @override_completion WHEN 'X' THEN N'IGNORE' ELSE @override_completion END;
IF @override_reliability IS NOT NULL SET @override_msg = @override_msg + N' RELIABILITY=' + CASE @override_reliability WHEN 'X' THEN N'IGNORE' ELSE @override_reliability END;
IF @override_speed IS NOT NULL SET @override_msg = @override_msg + N' SPEED=' + CASE @override_speed WHEN 'X' THEN N'IGNORE' ELSE @override_speed END;
IF @override_workload IS NOT NULL SET @override_msg = @override_msg + N' WORKLOAD=' + CASE @override_workload WHEN 'X' THEN N'IGNORE' ELSE @override_workload END;
RAISERROR(@override_msg, 10, 1) WITH NOWAIT;
DECLARE @weight_msg nvarchar(500) = N'Effective weights: COMPLETION='
+ CONVERT(nvarchar(10), CONVERT(integer, @weight_completion * 100))
+ N', RELIABILITY=' + CONVERT(nvarchar(10), CONVERT(integer, @weight_reliability * 100))
+ N', SPEED=' + CONVERT(nvarchar(10), CONVERT(integer, @weight_speed * 100))
+ N', WORKLOAD=' + CONVERT(nvarchar(10), CONVERT(integer, @weight_workload * 100))
+ N' (sum=100)';
RAISERROR(@weight_msg, 10, 1) WITH NOWAIT;
END;
IF @Debug = 1
BEGIN
SET @debug_elapsed_ms = DATEDIFF(MILLISECOND, @debug_timer, SYSDATETIME());
RAISERROR(N'', 10, 1) WITH NOWAIT;
RAISERROR(N'DEBUG: === Effective Parameters ===', 10, 1) WITH NOWAIT;
RAISERROR(N'DEBUG: @DaysBack=%i @LongRunningMinutes=%i @FailureThreshold=%i', 10, 1, @DaysBack, @LongRunningMinutes, @FailureThreshold) WITH NOWAIT;
RAISERROR(N'DEBUG: @TimeLimitExhaustionPct=%i @ThroughputWindowDays=%i @TopN=%i', 10, 1, @TimeLimitExhaustionPct, @ThroughputWindowDays, @TopN) WITH NOWAIT;
DECLARE @debug_efficacy_days integer = ISNULL(@EfficacyDaysBack, @DaysBack);
DECLARE @debug_efficacy_detail integer = ISNULL(@EfficacyDetailDays, 14);
RAISERROR(N'DEBUG: @EfficacyDaysBack=%i (resolved) @EfficacyDetailDays=%i (resolved)', 10, 1, @debug_efficacy_days, @debug_efficacy_detail) WITH NOWAIT;
DECLARE @debug_expert integer = CONVERT(integer, @ExpertMode);
DECLARE @debug_skip integer = CONVERT(integer, @SkipHistory);
DECLARE @debug_single integer = CONVERT(integer, @SingleResultSet);
DECLARE @debug_obf integer = CONVERT(integer, @Obfuscate);
RAISERROR(N'DEBUG: @ExpertMode=%i @SkipHistory=%i @SingleResultSet=%i @Obfuscate=%i', 10, 1, @debug_expert, @debug_skip, @debug_single, @debug_obf) WITH NOWAIT;
DECLARE @debug_w_comp integer = CONVERT(integer, @weight_completion * 100);
DECLARE @debug_w_rel integer = CONVERT(integer, @weight_reliability * 100);
DECLARE @debug_w_spd integer = CONVERT(integer, @weight_speed * 100);
DECLARE @debug_w_wl integer = CONVERT(integer, @weight_workload * 100);
RAISERROR(N'DEBUG: Weights: COMPLETION=%i%% RELIABILITY=%i%% SPEED=%i%% WORKLOAD=%i%%',
10, 1, @debug_w_comp, @debug_w_rel, @debug_w_spd, @debug_w_wl) WITH NOWAIT;
RAISERROR(N'DEBUG: Initialization complete (%i ms)', 10, 1, @debug_elapsed_ms) WITH NOWAIT;
RAISERROR(N'', 10, 1) WITH NOWAIT;
END;
/* BUG-07: orphan threshold now a named constant (was hardcoded 60 minutes).
Default 2880 = 48 hours, matching sp_StatUpdate @OrphanedRunThresholdHours default.
A run still in progress (no END record) is only classified as orphaned once this
threshold has elapsed; runs started more recently are excluded to avoid false C1 alerts. */
DECLARE @OrphanedRunThresholdMinutes integer = 2880;
/*
============================================================================
PERSISTENT HISTORY TABLE (incremental -- skip XML re-parsing on repeat runs)
Inspired by sp_Blitz / sp_PressureDetector baseline tables. The first run
parses all CommandLog XML. Subsequent runs only parse new rows (ID > max
previously cached), then merge into the persistent table. This cuts repeat
execution time dramatically on servers with large CommandLog tables.
Table lives in the same database as CommandLog (so it travels with backups).
Opt out with @SkipHistory = 1 for ephemeral/test scenarios.
============================================================================
*/
DECLARE
@history_ref nvarchar(500),
@history_exists bit = 0,
@history_max_id integer = 0,
@history_max_run_start datetime2(3) = NULL;
SET @history_ref = QUOTENAME(@commandlog_db) + N'.dbo.StatUpdateDiagHistory';
IF @SkipHistory = 0
BEGIN
/* Check if history table exists */
SET @sql = N'
IF OBJECT_ID(N''' + REPLACE(@history_ref, N'''', N'''''') + N''', N''U'') IS NOT NULL
SET @exists = 1;
ELSE
SET @exists = 0;
';
EXECUTE sys.sp_executesql
@sql,
N'@exists bit OUTPUT',
@exists = @history_exists OUTPUT;
/* Auto-create if missing */
IF @history_exists = 0
BEGIN
SET @sql = N'
CREATE TABLE ' + @history_ref + N'
(
SnapshotID integer IDENTITY(1,1) NOT NULL,
CapturedAt datetime2(3) NOT NULL DEFAULT SYSDATETIME(),
MaxCommandLogID integer NOT NULL,
RunLabel nvarchar(100) NOT NULL,
StartTime datetime2(3) NOT NULL,
HealthScore integer NULL,
OverallGrade char(1) NULL,
CompletionPct decimal(5,1) NULL,
AvgSecPerStat decimal(10,1) NULL,
WorkloadCoveragePct decimal(5,1) NULL,
HighCpuFirstQuartilePct decimal(5,1) NULL,
MinutesToHighCpuComplete decimal(10,1) NULL,
StatsFound integer NULL,
StatsProcessed integer NULL,
StatsFailed integer NULL,
StopReason nvarchar(50) NULL,
DurationSeconds integer NULL,
IsQSRun bit NOT NULL DEFAULT 0,
DiagVersion varchar(20) NOT NULL,
CONSTRAINT PK_StatUpdateDiagHistory PRIMARY KEY CLUSTERED (SnapshotID) WITH (DATA_COMPRESSION = PAGE),
INDEX IX_StatUpdateDiagHistory_RunLabel UNIQUE NONCLUSTERED (RunLabel) WITH (DATA_COMPRESSION = PAGE)
);
';
EXECUTE sys.sp_executesql @sql;
RAISERROR(N' Created persistent history table: %s', 10, 1, @history_ref) WITH NOWAIT;
SET @history_exists = 1;
END
ELSE
BEGIN
/* gh-472: Auto-upgrade existing StatUpdateDiagHistory tables from older diag versions.
COL_LENGTH returns NULL when column missing. Each ALTER gated independently so
partial upgrades resume cleanly. */
SET @sql = N'
IF COL_LENGTH(''' + @history_ref + N''', ''HealthScore'') IS NULL
ALTER TABLE ' + @history_ref + N' ADD HealthScore integer NULL;
IF COL_LENGTH(''' + @history_ref + N''', ''OverallGrade'') IS NULL
ALTER TABLE ' + @history_ref + N' ADD OverallGrade char(1) NULL;
IF COL_LENGTH(''' + @history_ref + N''', ''CompletionPct'') IS NULL
ALTER TABLE ' + @history_ref + N' ADD CompletionPct decimal(5,1) NULL;
IF COL_LENGTH(''' + @history_ref + N''', ''AvgSecPerStat'') IS NULL
ALTER TABLE ' + @history_ref + N' ADD AvgSecPerStat decimal(10,1) NULL;
IF COL_LENGTH(''' + @history_ref + N''', ''WorkloadCoveragePct'') IS NULL
ALTER TABLE ' + @history_ref + N' ADD WorkloadCoveragePct decimal(5,1) NULL;
IF COL_LENGTH(''' + @history_ref + N''', ''HighCpuFirstQuartilePct'') IS NULL
ALTER TABLE ' + @history_ref + N' ADD HighCpuFirstQuartilePct decimal(5,1) NULL;
IF COL_LENGTH(''' + @history_ref + N''', ''MinutesToHighCpuComplete'') IS NULL
ALTER TABLE ' + @history_ref + N' ADD MinutesToHighCpuComplete decimal(10,1) NULL;
';
EXECUTE sys.sp_executesql @sql;
/* Get watermark: max CommandLog ID already processed */
SET @sql = N'
SELECT @max_id = ISNULL(MAX(MaxCommandLogID), 0),
@max_start = MAX(StartTime)
FROM ' + @history_ref + N';
';
EXECUTE sys.sp_executesql
@sql,
N'@max_id integer OUTPUT, @max_start datetime2(3) OUTPUT',
@max_id = @history_max_id OUTPUT,
@max_start = @history_max_run_start OUTPUT;
DECLARE @history_row_count integer;