Priority-based statistics maintenance for SQL Server 2016+
Updates worst stats first. Stops when you tell it to. Tells you if it got killed.
📸 See It Run · 🎯 Why This Exists · 🚀 Quick Start · 📋 Requirements · ⚙️ Presets · 🛠️ Common Scenarios · 📖 Parameter Reference · 🔍 Environment Detection · 📊 Monitoring · 🩺 Diagnostic Tool · 🔬 Extended Events · 🔄 Migrating from v2 · 📜 Version History · ⚖️ When to Use This · 🙏 Acknowledgments
The screenshots below are real @Ansi = 'Y' console captures -- not mockups. Numbers vary with your workload.
A run, live -- worst stats first, per-stat progress with a live ETA, and a clean summary where every stat is accounted for:
The diagnostic dashboard -- letter grades your boss understands and your monitoring can alert on. RELIABILITY is a D because two runs got killed; the tool noticed, even if nobody else did:
It reads the room -- @Debug = 1 reports what changes how statistics behave: active stats-relevant trace flags (with guidance), CPU/NUMA, Resource Governor, and tempdb headroom -- and flags what will bite you:
It doesn't just diagnose -- it prescribes. Copy-paste the EXEC it built for your server:
🎨 About the color: every screenshot above is the tool's own output with
@Ansi = 'Y'(opt-in; default is plain text). Forsp_StatUpdatethe run/environment console is colorized; forsp_StatUpdate_Diag,@Ansi = 'Y'also renders the Dashboard and Recommendations as colored text on the Messages stream, in addition to the normal result sets. The escape codes are console-only and never touch CommandLog, OUTPUT parameters, or result sets.Color renders in Windows Terminal, VS Code's integrated terminal, iTerm2/Terminal.app, Linux terminals,
sqlcmd | less -R, and CI log viewers. It shows raw escape codes (so leave@Ansioff) in SSMS, Azure Data Studio, DataGrip/DBeaver, PowerShell ISE, legacycmd.exe, SQL Agent job logs, and files -- i.e. don't enable it for SSMS or Agent jobs. Only basic 16-color SGR codes are used, so wherever ANSI works at all, these do.
| Problem | Fix |
|---|---|
| Alphabetical ordering | @SortOrder = 'MODIFICATION_COUNTER' -- worst first |
| 10-hour jobs killed at 5 AM | @TimeLimit -- stops gracefully, logs what's left |
| "Did it finish or get killed?" | START/END markers in CommandLog |
| NORECOMPUTE orphans | @TargetNorecompute = 'Y' -- finds and refreshes them |
| Large stats that never finish | @LongRunningThresholdMinutes -- auto-reduce sample rate |
| Query Store knows what's hot | @QueryStore = 'CPU' -- prioritize by workload metric |
| QS enrichment too slow | @QueryStoreTopPlans = 200 -- parse only top N plans |
| "Query Store proves the spill" | It doesn't -- XE sort_warning proves the UPDATE STATISTICS spill; QS only ranks candidates |
| Cascading failures | @FailFast = 1 -- stop on first error |
| AG secondary falls behind | @MaxAGRedoQueueMB -- pauses when redo queue is deep |
| tempdb pressure during FULLSCAN | @MinTempdbFreeMB -- checks before each stat update |
| Azure DTU/vCore concerns | Auto-detects Azure SQL DB vs MI, platform-specific warnings |
| Priority pass finishes early | @MopUpPass = 'Y' -- broad sweep with remaining time |
-- 1. Install prerequisites (Ola Hallengren's CommandLog table)
-- Download from: https://ola.hallengren.com/scripts/CommandLog.sql
-- 2. Install sp_StatUpdate
-- Run sp_StatUpdate.sql in your maintenance database
-- 3. Run statistics maintenance
EXEC dbo.sp_StatUpdate
@Databases = N'YourDatabase',
@TimeLimit = 3600; -- 1 hour limit
-- Defaults: @Preset='DEFAULT', @TargetNorecompute='BOTH', @LogToTable='Y'DROP-IN COMPATIBLE with Ola Hallengren's SQL Server Maintenance Solution.
| Requirement | Details |
|---|---|
| SQL Server | 2016+ (uses STRING_SPLIT). 2016 SP2+ recommended for MAXDOP support |
| Azure SQL | Database (EngineEdition 5), Managed Instance (8), and Edge (9) supported |
| dbo.CommandLog | CommandLog.sql or set @LogToTable = 'N' |
| dbo.Queue | Queue.sql -- only for @StatsInParallel = 'Y' |
Note: dbo.QueueStatistic is auto-created on first parallel run. dbo.CommandExecute is NOT required.
v3 uses a preset-first API. Choose a preset, then override individual parameters as needed. Explicit parameters always win over preset defaults.
| Preset | TimeLimit | SortOrder | QueryStore | MopUp | Sample | Description |
|---|---|---|---|---|---|---|
DEFAULT |
18000 (5h) | MODIFICATION_COUNTER | OFF | N | auto | Balanced default for any workload |
NIGHTLY |
3600 (1h) | QUERY_STORE | CPU | Y | auto | QS-prioritized nightly job with mop-up |
WEEKLY_FULL |
14400 (4h) | QUERY_STORE | CPU | Y | 100 | Comprehensive weekly: FULLSCAN + lower thresholds |
OLTP_LIGHT |
1800 (30m) | MODIFICATION_COUNTER | OFF | N | auto | Low-impact OLTP: high threshold, inter-stat delay |
WAREHOUSE |
unlimited | ROWS | OFF | N | 100 | Data warehouse: FULLSCAN, no time limit |
-- Nightly maintenance (1hr, QS-prioritized, mop-up)
EXEC dbo.sp_StatUpdate @Preset = 'NIGHTLY', @Databases = 'USER_DATABASES';
-- Weekly comprehensive (4hr, FULLSCAN)
EXEC dbo.sp_StatUpdate @Preset = 'WEEKLY_FULL', @Databases = 'USER_DATABASES';
-- OLTP with minimal impact (30min, high thresholds, delays)
EXEC dbo.sp_StatUpdate @Preset = 'OLTP_LIGHT', @Databases = 'MyOLTPDatabase';
-- Data warehouse full refresh (no limit, FULLSCAN)
EXEC dbo.sp_StatUpdate @Preset = 'WAREHOUSE', @Databases = 'MyDW';
-- Preset + overrides: NIGHTLY preset but with 2hr time limit
EXEC dbo.sp_StatUpdate @Preset = 'NIGHTLY', @Databases = 'USER_DATABASES', @TimeLimit = 7200;-- Nightly job: 11 PM - 4 AM window (5 hours)
EXEC dbo.sp_StatUpdate
@Databases = N'USER_DATABASES, -DevDB, -ReportingDB',
@TimeLimit = 18000,
@SortOrder = N'MODIFICATION_COUNTER'; -- Worst stats first-- Let Query Store tell you what matters
EXEC dbo.sp_StatUpdate
@Databases = N'Production',
@QueryStore = N'CPU', -- Or DURATION, READS, AVG_CPU, MEMORY_GRANT, etc.
@SortOrder = N'QUERY_STORE',
@TimeLimit = 3600;
-- Large QS catalog? Limit XML plan parsing to top 200 plans by CPU
EXEC dbo.sp_StatUpdate
@Databases = N'Production',
@QueryStore = N'CPU',
@QueryStoreTopPlans = 200, -- Default 500. NULL = unlimited
@SortOrder = N'QUERY_STORE',
@TimeLimit = 3600;Available QS metrics: CPU, DURATION, READS, EXECUTIONS, AVG_CPU, MEMORY_GRANT, TEMPDB_SPILLS (SQL 2017+), PHYSICAL_READS, AVG_MEMORY, WAITS
⚡ Performance note: Phase 6 (QS enrichment) parses plan XML to find table references. On databases with 10,000+ QS plans, this can take minutes. @QueryStoreTopPlans limits XML parsing to the most impactful plans. The proc also skips Phase 6 entirely when QS has no recent runtime stats within @QueryStoreRecentHours.
-- Find and refresh forgotten NORECOMPUTE stats
EXEC dbo.sp_StatUpdate
@Databases = N'Production',
@TargetNorecompute = N'Y',
@ModificationThreshold = 50000,
@TimeLimit = 1800;-- Pause if any secondary falls behind by 500 MB redo
EXEC dbo.sp_StatUpdate
@Databases = N'USER_DATABASES',
@MaxAGRedoQueueMB = 500,
@MaxAGWaitMinutes = 10, -- Wait up to 10 min for drain
@TimeLimit = 3600;-- Stats that historically took >2 hours get 5% sample
EXEC dbo.sp_StatUpdate
@Databases = N'Production',
@LongRunningThresholdMinutes = 120,
@LongRunningSamplePercent = 5,
@TimeLimit = 14400;EXEC dbo.sp_StatUpdate
@Databases = N'Production',
@Execute = N'N',
@WhatIfOutputTable = N'#Preview',
@Debug = 1;
SELECT * FROM #Preview ORDER BY SequenceNum;-- 2-hour window: priority pass first, then broad sweep with remaining time
EXEC dbo.sp_StatUpdate
@Databases = N'USER_DATABASES',
@TimeLimit = 7200,
@MopUpPass = N'Y',
@MopUpMinRemainingSeconds = 120;The priority pass applies your configured thresholds and sort order. If it completes
with time to spare, the mop-up pass discovers every stat with modification_counter > 0
that wasn't already updated in this run and processes them by modification count descending.
Requires @LogToTable = 'Y' and @Execute = 'Y'. Not compatible with @StatsInParallel.
-- Stop at 4 AM regardless of when the job started
EXEC dbo.sp_StatUpdate
@Databases = N'USER_DATABASES',
@StopByTime = N'04:00';Run EXEC sp_StatUpdate @Help = 1 for complete documentation including operational notes and preset details.
v3 has 42 input parameters (was 58 in v2) plus 11 OUTPUT parameters. 23 parameters from v2 were absorbed into preset-controlled internal variables. Explicit parameters always override preset defaults.
📖 Show all parameter tables (database/table selection, thresholds, execution control, sort orders, Query Store, sampling, safety checks, logging, parallel, OUTPUT params, StopReason/Warning values)
| Parameter | Default | Description |
|---|---|---|
@Statistics |
NULL |
Direct stat references: 'Schema.Table.Stat' (comma-separated, skips discovery) |
@Databases |
Current DB | USER_DATABASES, SYSTEM_DATABASES, ALL_DATABASES, AVAILABILITY_GROUP_DATABASES, wildcards (%Prod%), exclusions (-DevDB) |
@Tables |
All | Table filter (comma-separated Schema.Table) |
@ExcludeTables |
NULL |
Exclude tables by LIKE pattern (%Archive%) |
@ExcludeStatistics |
NULL |
Exclude stats by LIKE pattern (_WA_Sys%) |
| Parameter | Default | Description |
|---|---|---|
@Preset |
'DEFAULT' |
DEFAULT, NIGHTLY, WEEKLY_FULL, OLTP_LIGHT, WAREHOUSE |
@TargetNorecompute |
'BOTH' |
'Y'=NORECOMPUTE only, 'N'=regular only, 'BOTH'=all |
@ModificationThreshold |
Preset-dependent | Minimum modifications to qualify (DEFAULT=5000) |
@StaleHours |
NULL |
Minimum hours since last update |
@FilteredStatsMode |
'INCLUDE' |
Filtered statistics handling: INCLUDE, EXCLUDE, ONLY, PRIORITY (drift-boosted) |
@FilteredStatsStaleFactor |
2.0 |
Filtered stat counts as drifted when unfiltered_rows/rows exceeds this factor |
| Parameter | Default | Description |
|---|---|---|
@TimeLimit |
Preset-dependent | Max seconds (DEFAULT=18000). NULL = unlimited |
@StopByTime |
NULL |
Absolute wall-clock stop time ('04:00' = 4 AM). Overrides @TimeLimit |
@BatchLimit |
NULL |
Max stats per run |
@SortOrder |
Preset-dependent | Priority order (see below) |
@MopUpPass |
Preset-dependent | 'Y' = broad sweep after priority pass |
@Execute |
'Y' |
'N' for dry run |
@FailFast |
0 |
1 = abort on first error |
| Value | Description |
|---|---|
MODIFICATION_COUNTER |
Most modifications first (DEFAULT/OLTP_LIGHT preset) |
QUERY_STORE |
Highest Query Store metric first (NIGHTLY/WEEKLY_FULL preset) |
ROWS |
Largest tables first (WAREHOUSE preset) |
DAYS_STALE |
Oldest stats first |
PAGE_COUNT |
Largest tables by page count first |
FILTERED_DRIFT |
Filtered stats with drift first |
AUTO_CREATED |
User-created stats before auto-created |
RANDOM |
Random order |
| Parameter | Default | Description |
|---|---|---|
@QueryStore |
Preset-dependent | 'OFF' or metric name: CPU, DURATION, READS, EXECUTIONS, AVG_CPU, MEMORY_GRANT, TEMPDB_SPILLS, PHYSICAL_READS, AVG_MEMORY, WAITS, WAIT_CPU |
@QueryStoreTopPlans |
500 |
Max plans to XML-parse. NULL = unlimited. Lower = faster Phase 6 |
@QueryStoreMinExecutions |
100 |
Minimum plan executions to boost |
@QueryStoreRecentHours |
168 |
Only consider plans from last N hours (7 days) |
| Parameter | Default | Description |
|---|---|---|
@StatisticsSample |
Preset-dependent | NULL=SQL Server decides, 100=FULLSCAN |
@MaxDOP |
NULL |
MAXDOP for UPDATE STATISTICS (SQL 2016 SP2+) |
@LongRunningThresholdMinutes |
NULL |
Stats that took longer get forced sample rate |
@LongRunningSamplePercent |
10 |
Sample percent for long-running stats |
| Parameter | Default | Description |
|---|---|---|
@MaxAGRedoQueueMB |
NULL |
Pause when AG secondary redo queue exceeds this MB |
@MaxAGWaitMinutes |
10 |
Max minutes to wait for redo queue to drain |
@MinTempdbFreeMB |
NULL |
Min tempdb free space (MB). @FailFast=1 aborts, else warns |
📝 Two-phase logging: Like Ola Hallengren's
CommandExecute, sp_StatUpdate inserts a CommandLog row with NULLEndTimebefore each stat update, then updatesEndTimeon completion. Query in-progress stats with:SELECT * FROM dbo.CommandLog WHERE EndTime IS NULL AND CommandType = 'UPDATE_STATISTICS';
| Parameter | Default | Description |
|---|---|---|
@LogToTable |
'Y' |
Log to dbo.CommandLog |
@WhatIfOutputTable |
NULL |
Table for dry-run commands (@Execute = 'N' required) |
@MopUpMinRemainingSeconds |
60 |
Minimum seconds remaining to trigger mop-up |
@Debug |
0 |
1 = verbose diagnostic output |
| Parameter | Default | Description |
|---|---|---|
@StatsInParallel |
'N' |
'Y' = queue-based parallel processing via dbo.QueueStatistic |
| Parameter | Description |
|---|---|
@Version |
Procedure version string |
@VersionDate |
Procedure version date |
@StatsFoundOut |
Total qualifying stats discovered |
@StatsProcessedOut |
Stats attempted (succeeded + failed) |
@StatsSucceededOut |
Stats updated successfully |
@StatsFailedOut |
Stats that failed to update |
@StatsRemainingOut |
Stats not processed (time/batch limit) |
@DurationSecondsOut |
Total run duration in seconds |
@WarningsOut |
Collected warnings (see below) |
@StopReasonOut |
Why execution stopped (see below) |
COMPLETED, TIME_LIMIT, BATCH_LIMIT, FAIL_FAST, CONSECUTIVE_FAILURES, AG_REDO_QUEUE, TEMPDB_PRESSURE, NO_QUALIFYING_STATS, KILLED
LOW_UPTIME, BACKUP_RUNNING, AZURE_SQL, AZURE_MI, RESOURCE_GOVERNOR, AG_REDO_ELEVATED, TEMPDB_LOW, RLS_DETECTED, COLUMNSTORE_CONTEXT, QS_FORCED_PLANS, LOG_SPACE_HIGH, WIDE_STATS, FILTER_MISMATCH
Debug mode (@Debug = 1) automatically reports:
- SQL Server version and build number
- Cardinality Estimator version per database (Legacy CE 70 vs New CE 120+)
- Trace flags affecting statistics (2371, 9481, 2389/2390, 4139)
- DB-scoped configs (LEGACY_CARDINALITY_ESTIMATION)
- Hardware context (CPU count, memory, NUMA nodes, uptime)
- Azure platform (SQL DB vs Managed Instance vs Edge, with platform-specific guidance)
- AG primary status and redo queue depth
- tempdb free space
- Resource Governor active resource pools
When @Debug = 1, after discovery the proc checks each database for:
- Row-Level Security policies that may bias histograms
- Wide statistics (>8 columns) that increase tempdb/memory pressure
- Filtered index mismatches where stat filter differs from index filter
- Columnstore indexes where
modification_counterunderreports - Non-persisted computed columns with evaluation cost during stat updates
- Stretch Database tables (auto-skipped, deprecated feature)
- Query Store forced plans on updated tables (post-update check, automatic)
- Transaction log space >90% full during FULLSCAN operations
Every run returns a summary row:
Status StatusMessage StatsFound ...
-------------- -------------------------------------------------- ---------- ---
SUCCESS All 142 stat(s) updated successfully 142 ...
WARNING Incomplete: 47 stat(s) remaining (TIME_LIMIT) 189 ...
ERROR Failed: 3 stat(s), 47 remaining (FAIL_FAST) 150 ...
-- Recent runs: did they finish or get killed?
SELECT
CASE WHEN e.ID IS NOT NULL THEN 'Completed' ELSE 'KILLED' END AS Status,
s.StartTime,
e.ExtendedInfo.value('(/Summary/StopReason)[1]', 'nvarchar(50)') AS StopReason,
e.ExtendedInfo.value('(/Summary/StatsProcessed)[1]', 'int') AS Processed,
e.ExtendedInfo.value('(/Summary/StatsRemaining)[1]', 'int') AS Remaining
FROM dbo.CommandLog s
LEFT JOIN dbo.CommandLog e
ON e.CommandType = 'SP_STATUPDATE_END'
AND e.ExtendedInfo.value('(/Summary/RunLabel)[1]', 'nvarchar(100)') =
s.ExtendedInfo.value('(/Parameters/RunLabel)[1]', 'nvarchar(100)')
WHERE s.CommandType = 'SP_STATUPDATE_START'
ORDER BY s.StartTime DESC;DECLARE @Found int, @Processed int, @Failed int, @Remaining int,
@StopReason nvarchar(50), @Warnings nvarchar(max);
EXEC dbo.sp_StatUpdate
@Databases = N'Production',
@TimeLimit = 3600,
@StatsFoundOut = @Found OUTPUT,
@StatsProcessedOut = @Processed OUTPUT,
@StatsFailedOut = @Failed OUTPUT,
@StatsRemainingOut = @Remaining OUTPUT,
@StopReasonOut = @StopReason OUTPUT,
@WarningsOut = @Warnings OUTPUT;
-- Use outputs for alerting, logging, or conditional logic
IF @Failed > 0 OR @StopReason = 'CONSECUTIVE_FAILURES'
RAISERROR(N'Alert: Statistics maintenance had failures', 10, 1) WITH NOWAIT;
IF @Warnings LIKE '%AG_REDO_ELEVATED%'
RAISERROR(N'Note: AG redo queue was elevated during maintenance', 10, 1) WITH NOWAIT;-- Log progress to CommandLog every 50 stats (secure, access-controlled)
EXEC sp_StatUpdate @Databases = 'USER_DATABASES', @TimeLimit = 3600;
-- Query in-progress stats:
SELECT * FROM dbo.CommandLog WHERE EndTime IS NULL AND CommandType = 'UPDATE_STATISTICS';sp_StatUpdate_Diag analyzes CommandLog history and produces actionable recommendations. Two viewing modes: management-friendly dashboard or full DBA deep-dive.
-- Show your boss: letter grades + plain English recommendations
EXEC dbo.sp_StatUpdate_Diag;Returns 2 result sets:
| RS | Name | What It Shows |
|---|---|---|
| 1 | Executive Dashboard | A-F letter grades for Overall, Completion, Reliability, Speed, and Workload Focus |
| 2 | Recommendations | Severity-categorized findings with fix-it SQL. Includes I10: a synthesized EXEC sp_StatUpdate call tuned to your environment |
Example dashboard output:
Category Grade Score Headline
---------------- ----- ----- --------------------------------------------------------
OVERALL B 75 Statistics maintenance is healthy with minor opportunities...
COMPLETION A 92 Nearly all qualifying statistics are being updated each run.
RELIABILITY C 55 2 run(s) were killed before completing. Check SQL Agent...
SPEED A 90 Statistics are being updated very quickly (0.4 sec/stat).
WORKLOAD FOCUS B 78 Query Store prioritization is working well.
-- Full technical detail: 13 result sets
EXEC dbo.sp_StatUpdate_Diag @ExpertMode = 1;| RS | Name | Description |
|---|---|---|
| 1 | Executive Dashboard | Letter grades (always returned) |
| 2 | Recommendations | Findings with remediation SQL (always returned) |
| 3 | Run Health Summary | Aggregate metrics: total runs, killed, completion %, QS coverage |
| 4 | Run Detail | Per-run: duration, stats found/processed, stop reason, efficacy |
| 5 | Top Tables | Tables consuming the most maintenance time |
| 6 | Failing Statistics | Stats with repeated errors |
| 7 | Long-Running Statistics | Stats exceeding the duration threshold |
| 8 | Parameter Change History | How parameters changed across runs |
| 9 | Obfuscation Map | Only when @Obfuscate = 1 |
| 10 | Efficacy Trend (Weekly) | Week-over-week QS prioritization metrics |
| 11 | Efficacy Detail (Per-Run) | Run-over-run with delta vs prior |
| 12 | High-CPU Stat Positions | Top-workload stats from most recent run |
| 13 | QS Performance Correlation | Per-stat CPU trend: are queries getting faster after updates? |
After switching from modification-counter to Query Store CPU-based sort order, show leadership the impact:
-- Broad trending: last 100 days, close-up on last 14
EXEC dbo.sp_StatUpdate_Diag
@ExpertMode = 1,
@EfficacyDaysBack = 100,
@EfficacyDetailDays = 14;Key result sets for this story:
- RS 10 (Efficacy Trend): Weekly roll-up showing high-CPU stats reaching first quartile, workload coverage %, trend direction
- RS 11 (Efficacy Detail): Per-run showing completion %, time-to-critical stats, delta vs prior run
- RS 13 (QS Performance Correlation): Per-stat CPU before vs after -- "5 of 5 tracked stats show 8% lower query CPU"
- I7 check: Automatically detects the configuration change point and compares before/after metrics
- I8 check: Summarizes whether queries are actually faster after stat updates
The diagnostic tool auto-creates dbo.StatUpdateDiagHistory to track health scores over time. Each run inserts only new data (watermark-based, no duplicates).
-- View health score trend
SELECT CapturedAt, RunLabel, HealthScore, OverallGrade, CompletionPct, WorkloadCoveragePct
FROM dbo.StatUpdateDiagHistory
ORDER BY CapturedAt DESC;
-- Skip history creation (testing or ephemeral environments)
EXEC dbo.sp_StatUpdate_Diag @SkipHistory = 1;Customize the Executive Dashboard when you know certain issues are expected or irrelevant.
⚙️ Show @GradeOverrides / @GradeWeights details and examples
Force a specific letter grade (A/B/C/D/F) or exclude a category entirely (IGNORE).
-- "I know about the 2 killed runs -- don't penalize the score"
EXEC dbo.sp_StatUpdate_Diag @GradeOverrides = 'RELIABILITY=A';
-- "I don't use Query Store -- exclude workload focus from my score"
EXEC dbo.sp_StatUpdate_Diag @GradeOverrides = 'WORKLOAD=IGNORE';
-- Force multiple: known slow stats + don't care about QS
EXEC dbo.sp_StatUpdate_Diag
@GradeOverrides = 'SPEED=B, WORKLOAD=IGNORE';
-- "Only care about completion and speed -- ignore everything else"
EXEC dbo.sp_StatUpdate_Diag
@GradeOverrides = 'RELIABILITY=IGNORE, WORKLOAD=IGNORE';
-- Force a low grade to flag a known problem for management visibility
EXEC dbo.sp_StatUpdate_Diag @GradeOverrides = 'COMPLETION=F';Valid categories: COMPLETION, RELIABILITY, SPEED, WORKLOAD
Valid values: A, B, C, D, F (force grade), IGNORE (exclude from OVERALL score)
Change how much each category contributes to the OVERALL score. Values are integers that auto-normalize to sum to 100%.
-- Default weights: COMPLETION=30, RELIABILITY=25, SPEED=20, WORKLOAD=25
-- Single category override: bump completion importance
-- 50 + 25(default) + 20(default) + 25(default) = 120 -> normalized to 42/21/17/21
EXEC dbo.sp_StatUpdate_Diag @GradeWeights = 'COMPLETION=50';
-- Two categories: only care about completion and speed equally
-- 50 + 25(default) + 50 + 25(default) = 150 -> normalized to 33/17/33/17
EXEC dbo.sp_StatUpdate_Diag @GradeWeights = 'COMPLETION=50, SPEED=50';
-- Weight=0 is the same as IGNORE -- excludes category from OVERALL
EXEC dbo.sp_StatUpdate_Diag @GradeWeights = 'WORKLOAD=0';
-- All four explicit (auto-normalized, don't need to sum to 100)
EXEC dbo.sp_StatUpdate_Diag
@GradeWeights = 'COMPLETION=40, RELIABILITY=10, SPEED=30, WORKLOAD=20';-- Force reliability to A (known kills are expected) AND
-- weight completion heavily for management reporting
EXEC dbo.sp_StatUpdate_Diag
@GradeOverrides = 'RELIABILITY=A',
@GradeWeights = 'COMPLETION=40, WORKLOAD=40';Category Grade Score Headline
-------------- ----- ----- ---------------------------------------------------
OVERALL B 86 Statistics maintenance is healthy... [Overrides active]
COMPLETION A 100 Nearly all qualifying statistics are being updated...
RELIABILITY A 28 [OVERRIDE: A] 2 run(s) were killed...
SPEED - 0 [IGNORED] Updates are slow at 17.9 sec/stat...
WORKLOAD FOCUS D 50 Query Store prioritization is not enabled...
[OVERRIDE: A]-- grade forced; Detail column shows(actual score: 28)[IGNORED]-- excluded from OVERALL; Grade=-, Score=0[Overrides active]-- shown on OVERALL when any override/weight change is active
Weight normalization: Weights are integers that auto-normalize to sum to 100%. Passing a single category (e.g., 'COMPLETION=50') keeps the other three at their defaults (25, 20, 25), then all four are normalized together (50+25+20+25=120 -> 42/21/17/21%). Weight of 0 is equivalent to IGNORE.
Note: History table (StatUpdateDiagHistory) always uses hardcoded weights (30/25/20/25) -- overrides only affect the current dashboard view, not persisted scores.
Hash all database, table, and statistics names for safe external sharing. Prefixes (IX_, PK_, DB_, _WA_Sys_) are preserved so consultants can still reason about object types.
🔐 Show obfuscation setup, decoding, and PowerShell workflow
-- 1. Generate obfuscated report with a seed (keeps tokens stable across runs)
EXEC dbo.sp_StatUpdate_Diag
@Obfuscate = 1,
@ExpertMode = 1,
@ObfuscationSeed = N'acme-2026-q1';Output tokens look like: DB_7f2a, TBL_e4c1, IX_STAT_9b3d. The seed ensures the same object always maps to the same token -- so if a consultant says "TBL_e4c1 is slow", you can decode it consistently.
-- Basic: one-off obfuscated output (random hashes, no persistence)
EXEC dbo.sp_StatUpdate_Diag @Obfuscate = 1, @ExpertMode = 1;
-- Seeded: deterministic hashes (same name = same token every time)
EXEC dbo.sp_StatUpdate_Diag
@Obfuscate = 1,
@ExpertMode = 1,
@ObfuscationSeed = N'acme-2026-q1';
-- Seeded + persisted map table: decode tokens later without re-running
EXEC dbo.sp_StatUpdate_Diag
@Obfuscate = 1,
@ExpertMode = 1,
@ObfuscationSeed = N'acme-2026-q1',
@ObfuscationMapTable = N'dbo.DiagObfMap';
-- After running with @ObfuscationMapTable, the proc prints a decode query:
-- === Decode obfuscated tokens ===
-- SELECT ObjectType, OriginalName, ObfuscatedName
-- FROM dbo.DiagObfMap WHERE ObfuscatedName = N'<paste_token_here>';-Obfuscate requires -ObfuscationSeed. Unseeded, the tokens are bare
hashes of the real names and a recipient can recover dbo, Orders,
Production and your instance names from a short dictionary -- so the wrapper
refuses rather than write a file called SAFE_TO_SHARE that isn't. Keep the seed
constant across runs so two reports stay comparable; changing it renumbers
every token.
Each obfuscated run produces:
| File | Contains | Share externally? |
|---|---|---|
*_SAFE_TO_SHARE.{md,html,json} |
Obfuscated names only | Yes |
*_SAFE_TO_SHARE_csv/ |
One CSV per result set, obfuscated | Yes |
*_CONFIDENTIAL.{md,html,json} |
Real server/database/table names | No |
*_CONFIDENTIAL_csv/ |
One CSV per result set, real names | No |
*_CONFIDENTIAL_DECODE.sql |
Standalone T-SQL script to decode tokens | No |
The CSV folders are written on every run (suppress with -NoCsv). Each file
unions that result set across the whole fleet with a leading Server column,
which is the shape you want for Excel or Power BI -- the narrative report tells
you which instances to look at, the CSVs let you pivot across all of them.
# Generate reports for 3 servers -- Markdown format, seeded obfuscation
.\Invoke-StatUpdateDiag.ps1 `
-Servers "PROD-SQL01", "PROD-SQL02", "PROD-SQL03" `
-Obfuscate `
-ObfuscationSeed "acme-2026-q1" `
-OutputFormat Markdown `
-OutputPath "C:\temp\diag"
# Output:
# C:\temp\diag\sp_StatUpdate_Diag_20260310_SAFE_TO_SHARE.md <-- send this
# C:\temp\diag\sp_StatUpdate_Diag_20260310_CONFIDENTIAL.md <-- keep this
# C:\temp\diag\sp_StatUpdate_Diag_20260310_CONFIDENTIAL_DECODE.sql
# Also persist the map table on each server for later decoding
.\Invoke-StatUpdateDiag.ps1 `
-Servers "PROD-SQL01", "PROD-SQL02" `
-Obfuscate `
-ObfuscationSeed "acme-2026-q1" `
-ObfuscationMapTable "dbo.DiagObfMap" `
-OutputPath "C:\temp\diag"Without -Obfuscate, a single report file is produced (no suffix).
1. DBA runs: Invoke-StatUpdateDiag.ps1 -Servers ... -Obfuscate -ObfuscationSeed "..."
2. DBA sends: *_SAFE_TO_SHARE.md to consultant (no real names visible)
3. Consultant: "On SRV_1A2B3C4D, TBL_e4c1 has a C2 finding -- stat IX_STAT_9b3d fails every run"
4. DBA decodes: Opens _CONFIDENTIAL_DECODE.sql in SSMS, searches for SRV_1A2B3C4D and TBL_e4c1
5. DBA finds: SRV_1A2B3C4D = PROD-SQL07, TBL_e4c1 = dbo.OrderHistory, IX_STAT_9b3d = IX_OrderHistory_Date
6. DBA fixes: The actual object, shares updated SAFE_TO_SHARE report to confirm
When a consultant returns findings referencing tokens like TBL_e4c1, you have two options:
Option A: Use the decode SQL file (no server access needed)
The _CONFIDENTIAL_DECODE.sql file is a standalone T-SQL script with the full map in a temp table:
-- 1. Open _CONFIDENTIAL_DECODE.sql in SSMS and execute it (creates #ObfuscationMap)
-- 2. Decode a specific token from the consultant's findings:
SELECT * FROM #ObfuscationMap WHERE ObfuscatedName = N'TBL_e4c1';
-- 3. Decode multiple tokens at once:
SELECT * FROM #ObfuscationMap WHERE ObfuscatedName IN (N'TBL_e4c1', N'IX_STAT_9b3d', N'DB_7f2a');
-- 4. Full map sorted by server:
SELECT * FROM #ObfuscationMap ORDER BY ServerName, ObjectType, OriginalName;Option B: Query the persisted map table on the server
If you used @ObfuscationMapTable (T-SQL) or -ObfuscationMapTable (PowerShell):
-- Decode a single token
SELECT ObjectType, OriginalName, ObfuscatedName
FROM dbo.DiagObfMap
WHERE ObfuscatedName = N'TBL_e4c1';
-- Export full map to CSV (useful for Excel cross-referencing)
-- In SSMS: Results to File, then run:
SELECT ObjectType, OriginalName, ObfuscatedName
FROM dbo.DiagObfMap
ORDER BY ObjectType, OriginalName;
-- Decode across multiple servers via linked servers
SELECT 'PROD-SQL01' AS [Server], ObjectType, OriginalName, ObfuscatedName
FROM [PROD-SQL01].master.dbo.DiagObfMap
UNION ALL
SELECT 'PROD-SQL02', ObjectType, OriginalName, ObfuscatedName
FROM [PROD-SQL02].master.dbo.DiagObfMap
ORDER BY [Server], ObjectType, OriginalName;🔐 How obfuscation works:
- With a seed: Hashes are deterministic -- the same object always produces the same token across servers, runs, and time. This means
TBL_e4c1in Monday's report is the same table asTBL_e4c1in Friday's report. - Without a seed: Hashes are random per run. Useful for one-off sharing but tokens can't be correlated across runs.
- The map table appends on each run (no data loss from prior runs)
- The
_CONFIDENTIAL_DECODE.sqlfile is standalone -- works in any SSMS session, no server access needed - Without the seed, the map, or the decode file, obfuscated tokens cannot be reversed (HASHBYTES is one-way)
-- Last 90 days, only top 5 items, long-running threshold at 15 minutes
EXEC dbo.sp_StatUpdate_Diag
@DaysBack = 90,
@TopN = 5,
@LongRunningMinutes = 15,
@ExpertMode = 1;
-- CommandLog in a different database
EXEC dbo.sp_StatUpdate_Diag @CommandLogDatabase = N'DBATools';
-- Single result set mode (JSON) for programmatic consumption
EXEC dbo.sp_StatUpdate_Diag @SingleResultSet = 1, @ExpertMode = 1;.\Invoke-StatUpdateDiag.ps1 `
-Servers "Server1", "Server2,2500", "Server3" `
-CommandLogDatabase "Maintenance" `
-OutputPath ".\diag_output" `
-OutputFormat MarkdownCross-server analysis detects version skew and parameter inconsistencies.
| Severity | ID | Checks |
|---|---|---|
| CRITICAL | C1-C5 | Killed runs, repeated stat failures, time limit exhaustion, degrading throughput, sample rate degradation |
| WARNING | W1-W10 | Suboptimal parameters, long-running stats, stale-stats backlog, overlapping runs, QS not effective, excessive overhead, mop-up ineffective, lock timeout ineffective, parameter churn |
| INFO | I1-I5 | Run health trends, parameter history, top tables by cost, unused features, version history |
| INFO | I6 | QS efficacy: "10 of 10 highest-workload stats updated in first 1 minute" |
| INFO | I7 | QS inflection: before/after comparison when QS prioritization was enabled |
| INFO | I8 | QS performance trend: per-stat CPU correlation across runs |
| INFO | I10 | Recommended configuration: synthesized EXEC call based on diagnostic findings |
| INFO | I11-I14 | Failure clustering, QS coverage drift, parallel opportunity, mop-up missing pagecount |
📖 Show full Diag parameter table
| Parameter | Default | Description |
|---|---|---|
@DaysBack |
30 |
History window in days |
@ExpertMode |
0 |
0 = dashboard + recommendations, 1 = all 13 result sets |
@SkipHistory |
0 |
1 = skip persistent history table |
@GradeOverrides |
NULL |
Force grades or ignore categories: 'RELIABILITY=A, SPEED=IGNORE' |
@GradeWeights |
NULL |
Custom category weights (auto-normalized to 100%): 'COMPLETION=50, WORKLOAD=50' |
@Obfuscate |
0 |
1 = hash all names for external sharing |
@ObfuscationSeed |
NULL |
Salt for deterministic hashing |
@ObfuscationMapTable |
NULL |
Persist obfuscation map to a table |
@EfficacyDaysBack |
NULL |
QS efficacy broad window (NULL = @DaysBack) |
@EfficacyDetailDays |
NULL |
QS efficacy close-up window (NULL = 14) |
@LongRunningMinutes |
10 |
Threshold for long-running stat detection |
@FailureThreshold |
3 |
Same stat failing N+ times = CRITICAL |
@TimeLimitExhaustionPct |
80 |
Warn if >X% of runs hit time limit |
@ThroughputWindowDays |
7 |
Window for throughput trend comparison; C4 compares the recent N days against the preceding N days (requires @DaysBack >= 2 x N) |
@TopN |
20 |
Top N items in detail result sets |
@CommandLogDatabase |
NULL |
CommandLog location (NULL = current DB) |
@SingleResultSet |
0 |
1 = JSON-formatted single result set |
@Debug |
0 |
1 = verbose output |
sp_StatUpdate_XE_Session.sql builds a ready-to-run session (sp_StatUpdate_Monitor) for when you'd rather watch a run than autopsy it afterward:
-- 1. Run sp_StatUpdate_XE_Session.sql to create the session
-- 2. Start it before the maintenance job
ALTER EVENT SESSION [sp_StatUpdate_Monitor] ON SERVER STATE = START;
-- 3. Analysis queries (incl. spill-to-CommandLog correlation) live at the bottom of the scriptCaptures the START and COMPLETION of every UPDATE STATISTICS statement, errors, lock waits and escalation, attentions (cancels/timeouts), deadlocks -- and, as of session v2.2, sort_warning (plus hash_warning as supporting context): the truthful proof surface for a sort spill in the stats-update statement itself.
Preselection is not proof. Query Store's TEMPDB_SPILLS / WAITS / WAIT_CPU metrics are excellent for choosing which stats to update before a run -- but a DDL statement barely registers in Query Store wait data, so those metrics can't prove the UPDATE STATISTICS command actually spilled. sort_warning fires from the spilling statement, filtered to the generated command text, with context_info carrying the run tag; analysis query 7 ties each spill back to its CommandLog row by time. Rank with Query Store; prove with XE.
v3 simplifies the API from 58 (v2) to 42 input parameters. Most v2 scripts work with minor changes.
🔄 Show the full v2 → v3 parameter migration table and example
| v2 Parameter | v3 Equivalent |
|---|---|
@QueryStorePriority = 'Y', @QueryStoreMetric = 'CPU' |
@QueryStore = 'CPU' |
@DaysStaleThreshold = 7 |
@StaleHours = 168 |
@HoursStaleThreshold = 48 |
@StaleHours = 48 |
@Preset = 'NIGHTLY_MAINTENANCE' |
@Preset = 'NIGHTLY' |
@Preset = 'WAREHOUSE_AGGRESSIVE' |
@Preset = 'WAREHOUSE' |
@TieredThresholds = 1 |
Preset-controlled (always on for DEFAULT/NIGHTLY/WEEKLY_FULL) |
@ThresholdLogic = 'OR' |
Preset-controlled |
@ModificationPercent = 10 |
Preset-controlled |
@MaxConsecutiveFailures = 5 |
Preset-controlled |
@DelayBetweenStats = 2 |
Preset-controlled (OLTP_LIGHT uses 2s delay) |
@LockTimeout = 10 |
Preset-controlled (OLTP_LIGHT uses 10s) |
@CleanupOrphanedRuns = 'Y' |
Always on |
@PersistSamplePercent = 'Y' |
Always on (when supported) |
@IncludeSystemObjects = 'Y' |
Removed (system objects excluded) |
@IncludeIndexedViews = 'Y' |
Removed (indexed views always included) |
@GroupByJoinPattern = 'Y' |
Removed |
@ExposeProgressToAllSessions = 'Y' |
Removed |
@CompletionNotifyTable |
Removed |
@LogSkippedToCommandLog |
Removed |
@ReturnDetailedResults |
Removed |
@ProgressLogInterval |
Removed |
@StatisticsFromTable |
Removed |
@FilteredStatsMode |
Public again since v3.7.0 (was preset-controlled in v3.0-v3.6) |
-- v2 (old)
EXEC dbo.sp_StatUpdate
@Databases = N'USER_DATABASES',
@QueryStorePriority = N'Y',
@QueryStoreMetric = N'CPU',
@TieredThresholds = 1,
@TimeLimit = 3600,
@SortOrder = N'QUERY_STORE',
@MopUpPass = N'Y',
@LogToTable = N'Y';
-- v3 (new) -- same behavior, fewer params
EXEC dbo.sp_StatUpdate
@Preset = N'NIGHTLY',
@Databases = N'USER_DATABASES';Current version: 3.11.2.2026.08.02 (proc) · 2026.08.02.1 (diag)
📜 Show full changelog (60+ releases)
- 3.11.2 / Diag 2026.08.02.1 - two crash-class fixes found by auditing the codebase against a deep study of the DarlingData diagnostic procedures. (1)
DATEDIFF(MILLISECOND)int overflow: a CommandLog row spanning more than ~24.8 days (orphaned START whose END marker was written by orphan cleanup weeks later) raised error 535 and hard-failed the entire Diag run; the proc's TablePriority duration estimate read the same rows during discovery. Both now useDATEDIFF_BIG; Diag stores NULL for such spans (a duration measured to a cleanup marker is fiction) and the estimate filters them out. (2) Diag RS13ChangePctcolumns had no denominator floor: a 2 ms CPU baseline reported +249,900%, and a large enough delta overfloweddecimal(10,1)(error 8115) and killed the run -- now floored (NULL below 100 ms CPU / 0.1 ms-per-exec / 1 MB grant / 128 tempdb pages, matching the I8 headline constants) and clamped at +1000%. Regression tests seed both crash fixtures live. - 3.11.1 - o2md.54, found by a primary-source SQL 2025 compatibility audit: the plan-feedback awareness filter used a nonexistent
ACTIVEstate (sys.query_store_plan_feedbackhas no such state), silently degrading the count to PENDING_VALIDATION-only on every version -- now filters the four states a stats update could invalidate. Audit verified every other hardcoded QS value map (readonly_reason bitmask, wait categories, capture modes, version gates) against current docs; no other mismatches. - 3.11.0 / Diag 2026.07.30.11 - o2md.18 (maintainer decision): discovery-candidate log. The proc writes one compact
SP_STATUPDATE_REMAININGrow per run listing the qualifying stats left unprocessed at run end (capped 500, truncation-flagged; table-level in parallel skip-discovery mode) -- the population that was previously unobservable from CommandLog. Diag's W13PERPETUALLY_SKIPPEDconsumes it as the authoritative source (intersection across the last N runs, deepest-queued examples), keeping the old inference only as a fallback for older-proc runs. - 3.10.1 - o2md.53: the forced-plan baseline/delta filtered
plan_forcing_type = N'MANUAL'against an INT column -- conversion error on every SQL 2022+/2025 run, TRY/CATCH-swallowed, so the MANUAL-only filter never applied where the column existed. Now compares the integer value (1); live-verified capturing on SQL 2025. - 3.10.0 - Mop-up Query Store support (o2md.5) + two silent pre-existing bugs found verifying it. Phase 6 QS enrichment extracted into a shared fragment: the staged path reassembles it byte-identically (the two paths cannot drift) and mop-up executes the same fragment per database -- mop-up rows now log real
QSPlanCount/boost values and@SortOrder=QUERY_STOREis honored via post-enrichment re-rank (parallel TablePriority fan-out inherits it). Pre-existing bug 1: the empty-discovery result shape was missingis_critical(since the CriticalTables batch), so every zero-qualify database was silently skipped. Pre-existing bug 2 (worse):@mop_up_where_sqlcarried quadrupled quotes since the April gh-462 refactor -- serial and parallel-leader mop-up discovery had failed with a swallowed syntax error on every run for ~3 months while gates stayed green ("no additional stats found" looked normal). A live regression test now asserts mop-up discovery actually finds seeded work. - 3.9.0 - BEHAVIOR CHANGE (maintainer-authorized, o2md.7): adaptive sampling on by default in the NIGHTLY (10-min threshold) and WEEKLY_FULL (30-min) presets -- historically-slow stats fall back to the sampled rate instead of burning a third of the window on one FULLSCAN. DEFAULT/OLTP_LIGHT/WAREHOUSE unchanged; explicit
@LongRunningThresholdMinutes/@LongRunningSamplePercentstill override. To restore old preset behavior pass an unreachable threshold (e.g. 100000; NULL means "preset decides", validation floor is 1). - Diag 2026.07.30.10 - o2md.52:
@CriticalTablesparameter values leaked real table names into the obfuscated SAFE_TO_SHARE report (the obfuscation pass never rewrote#runs.CriticalTables, so W16/I17/RS8 echoed configured patterns verbatim). Fixed at the source with per-elementPAT_tokens; the one hardcoded example in I4d now uses obviously-fake placeholder names so leakage greps cannot false-trip. Regression test added. - Wrapper 2026.07.30 - o2md.51 + o2md.40: report styling and provider modernization. Markdown reports gain severity emoji, a GitHub-alert top-issues callout, grade badges, and collapsible per-server sections (theme-agnostic -- the viewer controls .md theming); a self-contained dark-themed HTML twin now always ships alongside the .md (embedded dark CSS, severity-colored callouts, zero external assets -- nothing leaks from SAFE_TO_SHARE files). SQL client: prefers
Microsoft.Data.SqlClientwhen resolvable, falls back to the runtime-bundledSystem.Data.SqlClientotherwise (install the modern package to opt in; no breakage without it). - Diag 2026.07.30.9 - o2md.24: I10
RECOMMENDED_CONFIGseverity-precedence rewrite. The "just run this" output previously consumed only 4 finding categories and contradicted higher-severity findings on the same server. Now: CRITICAL > WARNING > baseline echo, with disagreements rendered as explicit/* CONFLICT *///* CAVEAT */comment lines in the generated call;SILENT_NOOP_RUNblocks newly-enabling parallel and adds a version-gated upgrade caveat;PRIORITY_PASS_EMPTYhalves@ModificationThresholdon 3.7.0+ (upgrade-first caveat below); I8 trend data can enable@QueryStore=CPUunlessQS_NOT_EFFECTIVEcontradicts; and the stale v2-era gate that suppressed@MopUpPasson parallel configurations is gone (why 31/40 parallel fleet servers never saw it). - Diag 2026.07.30.8 - Maintainer-decision batch 1 (o2md.19, o2md.14, o2md.33, o2md.35): W3
STALE_BACKLOGdefers to C6 when every non-killed run confirmed zero processed (throughput advice is nonsense for pure no-op windows; mixed windows keep the kzx0 behavior); C4 caps at WARNING when either comparison window has under 5 runs, with thin-baseline + survivorship caveats in the evidence; AG-secondary by-design stops (AG_SECONDARY, StatsFound=0) are excluded from grading and surfaced as new INFO check I19AG_SECONDARY_BY_DESIGN-- while the genuine secondary-host defect signature (found>0, processed=0) still fires everything; RS5/RS7 gain additive reason-token columns (ImpactDataStatus,SampleDataStatus) explaining legitimately-blank QS/sample numerics. - Diag 2026.07.30.7 - o2md.50: consume the proc v3.8.5 observability fields. W6
EXCESSIVE_OVERHEADnow subtracts the four measured self-idle-time buckets (DelayWaitMs/AgRedoWaitMs/RetryBackoffMs/QueueClaimMsfrom the END Summary XML) from the overhead percentage, so the figure is genuinely-unattributed time -- and the evidence states whether the worst run's figure had measured idle excluded or predates measurement (pre-3.8.5). W5QS_NOT_EFFECTIVEfamily prefers the reliable per-rowFromMopUpflag over theQualifyReason=MOP_UPinference on 3.8.5+ runs, so priority-pass leftovers mislabeled by the session-scoped mop-up flag now correctly count as priority-pass evidence; older runs keep the inference. Cache table gainsFromMopUpvia the existing auto-upgrade path. - 3.8.5 - Observability: self-idle-time buckets + mop-up sort-order/tagging (o2md.46, o2md.49). The proc's own wait time (inter-stat delay, AG-redo recheck loop, retry backoff, parallel claim-cycle overhead) was invisible to Diag's overhead attribution -- four accumulators now emit in the END Summary XML as
DelayWaitMs/AgRedoWaitMs/RetryBackoffMs/QueueClaimMs(omitted when zero). Mop-up discovery (all three sites) now honors@SortOrderfor ROWS/PAGE_COUNT/DAYS_STALE/AUTO_CREATED/RANDOM instead of silently falling back to modification-counter order (QUERY_STORE ordering in mop-up remains a tracked design decision), and mop-up-discovered rows carry a per-rowFromMopUpflag in per-stat ExtendedInfo -- unlike the session-scopedQualifyReason=MOP_UP, it cannot mislabel priority-pass leftovers, giving Diag a reliable way to tell "no QS data" from "QS not applicable to this pass". - Diag 2026.07.30.6 - Stale v3 parameter references in user-facing recommendation/@Help text, prompted by a live user report that
@CleanupOrphanedRunswas appearing in Diag alert text even though v3 runs orphan cleanup automatically and removed the parameter. Auditing the same pattern found five more instances of the same defect class (v2 params absorbed into internal variables or removed outright, still referenced as if live):@HelpKilled Run Detection topic, W15'sPARALLEL_ORPHAN_BACKLOG/STALE_QUEUE_SWEEP/AZURE_SQL_MIrecurring-code recommendations, W6EXCESSIVE_OVERHEAD's recommendation and code comment (@DelayBetweenStats,@CommandLogRetentionDays), and an o2md.18(c) code comment (@LogSkippedToCommandLog). Two of the six were self-inflicted -- introduced by the immediately-prior 2026.07.30.4 batch (o2md.27) while fixing W6 -- caught only because the live report prompted auditing for the defect class rather than trusting the single reported instance. - Diag 2026.07.30.5 - Redundant/tautological findings + obfuscation hardening (o2md.43, o2md.44, o2md.28, o2md.36): W7
HIGH_IMPACT_STATS_DEPRIORITIZEDnow only fires when QUERY_STORE sort is actually active (was firing on ~half of any top-10 list by construction, unconditionally recommending a sort order that might already be on); I9WORKLOAD_CONCENTRATIONreframed as neutral descriptive text instead of an action item (Pareto principle, not a defect); W5/I6/I8's redundant "no QS data" (three findings, one fact, ~45 redundant/446 in the fleet sweep) collapsed to a single finding; obfuscation tokens widened from 6-hex (Schema was actually 4-hex, the confirmed cause of a fleet-wide collision) to 8-hex uniformly, and an unseeded run now auto-generates a random salt instead of hashing unsalted. - Diag 2026.07.30.4 - Diag correctness batch (o2md.15, o2md.23, o2md.25, o2md.27): C4
DEGRADING_THROUGHPUTrecommendation no longer suggests@StatsInParallelwhen the evidence already shows it enabled; I8 headline no longer mislabels a real improving-count-majority as "stable" just because the median magnitude is under the 5% threshold; I10 TimeLimit jitter normalization now uses MODE instead of a spread check any single outlier could defeat (40/40 fleet recommendations were carrying raw jittery values); W6EXCESSIVE_OVERHEAD's guard tightened (was firing at 93.4% "overhead" on a 34-stat run dominated by fixed cost) and its recommendation now says the figure is inferred, not measured, before pointing at discovery cost. - Diag 2026.07.30.3 - o2md.12: C6
SILENT_NOOP_RUNevidence now includes Version/StatsInParallel/DurationSeconds/StatsRemaining (previously only StartTime/StopReason/StatsFound -- the recommendation said "upgrade to 3.5.7+" without ever showing the installed version). Also fixedISNULL(StatsProcessed, 0) = 0conflating a genuinely-NULL StatsProcessed (older proc build, truncated END write) with a confirmed 0 -- CRITICAL C6 now requiresStatsProcessed = 0explicitly, and the NULL case gets its own new INFO-severity check (I18NOOP_UNCONFIRMED) instead of being silently mis-classified. - 3.8.4.2026.07.30 - o2md.8: the existing #163 deadlock-retry loop (up to 3 attempts, exponential backoff) retried error 1205 only -- extended to also retry 1222 (lock timeout), just as transient, previously burned as a hard failure on the first hit (100% of failures on one production server). Existing
DEADLOCK_RETRY_OK/FAILtext unchanged for 1205; newLOCK_TIMEOUT_RETRY_OK/FAILfor 1222. - Diag 2026.07.30.2 - ExampleCall audit sweep, round 1 (o2md.20, o2md.21): I15
HEAP_TIME_BUDGETno longer references@CollectHeapForwarding(removed in v3, made the emitted example fail outright on 7/40 fleet servers) -- points atsys.dm_db_index_physical_stats(...).forwarded_record_countdirectly instead. W2LONG_RUNNING_STATSExampleCall now includes@Databases(was silently targeting only the current database) and notes the ~10M-row sample cap when it applies. - 3.8.3.2026.07.30 - Query Store diagnostics + priority-pass/mop-up inversion warning (o2md.6, QuickieStore comparison):
wait_stats_capture_modeis now checked before WAITS/WAIT_CPU enrichment (capture-mode OFF previously produced silent zero enrichment indistinguishable from "no matching workload"); READ_ONLY Query Store state now decodesreadonly_reason(storage limit with current/max MB, AG secondary, single-user/emergency mode) instead of one generic message;query_capture_modeis surfaced when NONE/CUSTOM may be silently biasing which queries QS-based prioritization even sees; the forced-plan-failure-delta warning now sampleslast_force_failure_reason_descso it's self-contained instead of "go investigate". All four are additive diagnostics -- no change to qualification, ordering, or enrichment logic. Also: the priority pass qualifying 0 stats while mop-up (rawmodification_counter > 0, no threshold filtering) finds N > 0 is never legitimate -- it means priority-pass qualification is broken, not that the fleet has nothing to do. Now detected and surfaced via@WarningsOut/@WarningsCodesOut(PRIORITY_PASS_ZERO_MOPUP_FOUND) instead of mop-up silently absorbing the gap. - Diag 2026.07.30.1 - o2md.42: named
PRIMARY KEYconstraints on 5 temp tables (#stat_updates,#recommendations,#run_version_keys,#forced_plans,#qs_efficacy) unnamed. Temp table names get a per-session uniquifier; named constraints on those tables don't, so two concurrent Diag sessions on the same instance collided on the constraint name and the second session'sCREATE TABLEfailed outright. - 3.8.2.2026.07.28 - SILENT NO-OP: fleet analysis batch (o2md.1/.2/.3/.9): found by a 40-server production diagnostic sweep where ~50 nightly maintenance windows reported SUCCESS while processing zero statistics. Root cause: lazy mop-up branches that discover 0 stats for a claimed table fell through without a
CONTINUE, abandoning every remaining queued table with@stop_reason = COMPLETED. Also fixed:@remaining_statsreporting 0 when a worker joined an existing parallel queue, andStatsFound > 0 / StatsProcessed = 0now gradesStatus = ERRORand returns 2 so SQL Agent fails the job (previously only caught by the separate diagnostic tool, so no-ops went unnoticed for weeks). - Diag 2026.07.28.1 - Fleet analysis batch (o2md.10/11/13/16/17/18/22): checks that asserted more than their evidence supported. Includes a 100% false-positive fix (
CRITICAL_TABLES_NO_MATCHmisreading FOR XMLXSINIL-serialized empty strings as "set"), a root-cause attribution fix forSILENT_NOOP_RUN, and a fix forQS_PERFORMANCE_TRENDreporting impossible percentages from unbounded ratio averaging. - Diag 2026.07.25.1 -
@Ansiopt-in extended: also renders the Executive Dashboard and Recommendations as colorized text on the Messages stream, in addition to the normal result sets. - 3.8.1.2026.07.25 - MONITORING: per-stat CONTEXT_INFO refresh (sp_StatUpdate-03i3 / gh-538). The static CONTEXT_INFO set at proc entry (DB/Job/Preset only) is now refreshed every loop iteration with a compact
SU|<n>/<total>|<schema.table.stat>|Run:<label>payload, sosp_WhoIsActivemid-run shows which stat is currently being processed instead of just the entry-time snapshot. - 3.7.4.2026.07.25 - Spill proof: preselection vs. proof (gh-552): sp_StatUpdate's Query Store metrics (
TEMPDB_SPILLS,WAITS,WAIT_CPU) rank which stats to update before a run -- but a DDL statement barely registers in Query Store wait data, so they can't prove theUPDATE STATISTICScommand itself spilled.@Helpnow documents that distinction (new SPILL PROOF topic) and points tosp_StatUpdate_XE_Session.sql, which gainssort_warning(primary proof surface) plushash_warning(secondary context) events -- filtered to the generated command text,context_info-tagged, and correlated to theCommandLogrow by time via a new analysis query. Fixed a latent bug the new events exposed: the session DDL was built by concatenating string literals, which capped at 4000 nchars and silently truncated theCREATE EVENT SESSIONmid-token once the session grew past that -- now forced toNVARCHAR(MAX). No behavioral change to the main proc's QS enrichment, ranking, or sort logic. - 3.7.3.2026.07.23 - Publish early-rejection outcomes before severity-16 signaling (gh-555): the open-transaction guard and the ALREADY_RUNNING branch raised their severity-16 error BEFORE setting OUTPUT parameters, emitting the structured summary row, and (ALREADY_RUNNING) writing the gh-551
SP_STATUPDATE_DENIEDartifact. A caller wrapping EXEC in TRY/CATCH transfers control at the RAISERROR, so it received the exception without the summary row or the denial telemetry the artifact exists to provide. Both paths now publish first and signal last -- message/severity/state and return codes unchanged, and the StatUpdateLock transaction still commits before any signal. Known T-SQL limit (locked in by regression test): OUTPUT parameters are never copied back when the caller's CATCH intercepts the error, so TRY/CATCH callers should key off the streamed summary row, the denial CommandLog row, orERROR_MESSAGE(). Callers without TRY/CATCH see identical behavior except the error message now follows the result set in the stream. - 3.7.2.2026.07.23 - Phase 3B delta-enrichment cache key omits SchemaName: the gh-502
cl_lookupCTE joined CommandLog on DatabaseName + ObjectName + StatisticsName -- the same incomplete key fixed for the Phase 5B QS score cache in v3.7.1. Same-named table+stat pairs in different schemas could read each other's lastModificationCounter, producing wrongeffective_counterdeltas (a stat could be treated as already-current based on another schema's counter) and wrongmodification_velocity.SchemaNameis now part of the join key. - 3.7.1.2026.07.23 - QS score cache value + identity fix (gh-556): the gh-503 CommandLog-backed Query Store score cache had three defects. (1) Phase 6 zero-initialized
qs_priority_boostfor ALL candidates while live enrichment skipped cache hits -- every hit silently ranked as 0 (debug output still reported a fresh hit). Zero-init now applies to cache misses only, and the WAITS/WAIT_CPU wait-stats UPDATEs gain the same cache-hit exclusion. (2) The cache lookup never compared the cachedQSMetric-- a boost computed under CPU could be reused for a DURATION/WAITS run, violating the gh-503 invalidation contract. The lookup now requires cached metric = current metric (filtered inside the CTE, so an older same-metric row still hits after a metric flip-flop); legacy rows without aQSMetricelement are cache misses. (3) The cache key was DatabaseName + ObjectName + StatisticsName -- same-named table+stat pairs in different schemas shared scores.SchemaNameis now part of the join key. - Diag 2026.07.23.2 - Empty-CommandLog single-result envelope (gh-557): with
@SingleResultSet = 1and no usable CommandLog data, the early-return branch previously emitted only ResultSetIDs 1-2, silently breaking the advertised stable-ID contract (IDs 1-13 always present). IDs 3-13 now each carry one marker row whoseRowDataJSON containsDataStatus = "UNAVAILABLE",Reason = "NO_COMMANDLOG_DATA", and theResultSetName-- automation can distinguish unavailable source data from zero findings without message parsing (consumers key on theDataStatusproperty, never on text). IDs 1-2 keep their first-run content; the multi-result-set empty path (gh-304 placeholders) is unchanged. Also fixed: the empty-path ID 2RowDatasubquery was uncorrelated, concatenating all recommendation rows into one invalid JSON string per row -- now correlated per finding with sequentialRowNum, matching the normal-path RS 2 shape. - Diag 2026.07.23.1 - C4 adjacent-window fix (gh-558): the DEGRADING_THROUGHPUT check now compares the recent
@ThroughputWindowDaysagainst the immediately preceding window of equal length. Previously the PRIOR window had no lower bound, so every loaded run outside the recent window entered the baseline -- with defaults that meant 7 recent days vs 23 prior days, and raising@DaysBacksilently changed C4 results. Runs older than 2 x@ThroughputWindowDaysare now excluded from C4 entirely, and validation requires@DaysBack>= 2 x@ThroughputWindowDays(breaking: calls with@DaysBackbelow twice the window -- e.g.@DaysBack = 7with the default 7-day window -- now fail validation; pass a smaller@ThroughputWindowDays). - 3.7.0.2026.07.03 - QS attribution + guardrail batch. (1) Forced-plan failure attribution (gh-533): before touching any stat, the proc snapshots
force_failure_countfor every forced Query Store plan per database (MANUAL-only on SQL 2022+ so Automatic Plan Correction noise is excluded); after the run it computes the delta. Rising counts emit aQS_FORCED_PLAN_FAILURE_DELTAwarning naming per-database deltas, and the END Summary XML gains aForcedPlanFailureDeltaelement (omitted when zero) — the DBA can finally attribute "forced plan started failing overnight" to the stats job or rule it out. Databases whose baseline capture failed transiently are excluded from the delta so pre-existing failures are never misattributed. (2)@QueryStore = WAIT_CPU(gh-534): prioritizes by CPU-category waits only (sys.query_store_wait_statswait_category = 1), complementingWAITS(all stat-influenceable categories). (3) Budget-overshoot guard (gh-539): before each stat, estimated duration (MAX of the last 10 successful CommandLog updates — one 4-hour outlier must count, so not AVG) is checked against the remaining@TimeLimit/@StopByTimebudget; stats that cannot fit are deferred instead of blowing through the window. (4) Denial artifact (gh-551): a run refused withALREADY_RUNNINGnow writes a durableSP_STATUPDATE_DENIEDCommandLog row identifying the denied session and the lock holder (session, login time, lock acquire time); suppressed with@LogToTable = N'N'. (5) Filtered-drift proof surface (gh-554):FILTERED_DRIFTnow outranksQUERY_STORE_PRIORITYinQualifyReason, and@FilteredStatsMode/@FilteredStatsStaleFactorare public parameters again — the v3.0 API collapse had absorbed them with no preset exposing EXCLUDE/ONLY/PRIORITY, leaving those modes and the FILTERED_DRIFT sort order unreachable. (6) Preset contract docs (gh-553):@Helppreset topic rewritten as a bounded override contract listing the supported override set. (7) IO-corruption warning deduped via flag; fresh-stat physical row counts aggregated once per object. - Diag 2026.07.03.1 - I8/RS13 DataStatus (gh-532): the QS Performance Correlation surfaces no longer go silently empty on sparse Query Store history. Sparse I8 findings carry a machine-readable
[DataStatus: AVAILABLE|INSUFFICIENT_HISTORY|UNAVAILABLE; QSCpuRuns: N; TrackedStats: N]suffix, and RS 13 returns a single[NO DATA]sentinel row (full column list, reason text explaining what to enable) instead of zero rows whenever status is not AVAILABLE — including the empty-CommandLog first-run path and@SingleResultSetmode. - Diag 2026.07.02.2 - Phase-C diagnostic batch. @CriticalTables coverage: per-stat
IsCritical/CriticalSampleOverrideand the threeCritical*parameters are now ingested; new W16 CRITICAL_TABLES_NO_MATCH (configured pattern matched zero stats -- silent misconfiguration) and I17 CRITICAL_TABLES_COVERAGE (leadership summary with avg processing position proving@CriticalTablesFirstworks). Version-aware parameter gating (gh-520): runs from sp_StatUpdate < 2.15 (pre full-parameter-logging) are excluded from W1/I4 parameter reasoning -- on newer runs an absent element genuinely means NULL, so those checks stay sharp without false findings on old rows. Executive Dashboard TIME_LIMIT annotation (gh-521): when time-limit exhaustion crosses the C3 threshold, COMPLETION gains a[!] budget-capped, not workload-completebanner + CompletionReason detail (annotation only -- scores/grades unchanged). History watermark (gh-524):StatUpdateDiagHistorygainsMaxStartTime(purge/archive-safe) alongside the legacy ID watermark; RunLabel dedup guard unchanged. Hardening: all three#stat_updatesingestion builds start with aCONVERT(nvarchar(max), ...)operand -- sub-4000-char literal pieces previously made intermediate concatsnvarchar(4000)and silently truncated the assembled batch (the direct path lost its WHERE tail and ingested zero rows); cache CREATE brought into lockstep with its upgrade ALTERs. - 3.6.0.2026.07.02 - Telemetry contract batch. (1)
WarningsCodesin END Summary XML: the run's pipe-delimited warning codes (same tokens as@WarningsCodesOut) now land in theSP_STATUPDATE_ENDrow'sSummary/WarningsCodeselement on all five live END writers (four early-return paths + normal finalize), so CommandLog consumers are no longer blind to runtime warnings. (2) StopReason fix:COMPLETED_WITH_SKIPPED_DBSis applied before the END row is written — CommandLog previously always recorded plainCOMPLETED. (3)CONSECUTIVE_FAILURES_ELEVATED: new peak tracker emits this code when consecutive failures reached >= 3 mid-run without hitting the bailout;@HelpWARNINGS topic now documents the full format contract (every code token enumerated; codes may be added, never renamed/removed without a major version bump). (4)AZURE_SQL_EDGE: EngineEdition 9 gets its own platform message + warning code. (5) Operator polish: per-stat progress line gains a wall-clock + ETA suffix (once 5 stats have completed); startup advisory when a 0-1s@LockTimeoutis combined with@MaxConsecutiveFailures = 1(one lock wait would abort the run). - Diag 2026.07.02.1 - Phase-B diagnostic batch. Skip markers (
SKIPPED:/TOCTOU_SKIP) are excluded from#stat_updatesingestion, so they no longer pollute durations, appearance counts, or top-tables (NULL-safe predicate).Summary/StatsToctouandSummary/QSEnrichmentSkippedare now consumed directly (W5/I9b use the definitive signal with inference as fallback for old rows). I4 UNUSED_FEATURES catalog refreshed (@FirstTimeFullScanCapRows,@CriticalTables,@AbortOnIntegrityError— the latter surfaces when IO_CORRUPTION appears in StopReason or WarningsCodes). Dead v2GroupByJoinPatterningestion removed. Two new checks: W14 RECURRING_SAFETY_STOP (same safety StopReason — TEMPDB_PRESSURE, AG_REDO_QUEUE, LOG_SPACE_HIGH, IO_CORRUPTION, FAIL_FAST, CONSECUTIVE_FAILURES, BATCH_LIMIT — in >= 2 runs) and W15/I16 RECURRING_RUNTIME_WARNING (warning codes recurring across runs, severity-tiered environmental vs informational; consumes the newSummary/WarningsCodeselement; older runs without the element produce no false findings). - 3.5.9.2026.07.02 - Three fixes. (1) Fresh statistics finally qualify (gh-550, cf. Ola IndexOptimize #990): statistics that had never been computed (
last_updated IS NULL,modification_counter IS NULL) over populated tables were silently never updated — the Phase 4 outer gateeffective_counter > 0excluded them from every qualification branch, including the@StaleHoursrescue. Fresh candidates now get a targetedsys.partitionsphysical row count (fresh stats only — no blanket join) and a NEVER_UPDATED rescue branch in Phase 4;QualifyReasonreportsNEVER_UPDATED. Fresh stats on empty tables still do not qualify. (2) New@AbortOnIntegrityError bit = 1(gh-549): default 1 preserves the existing abort-on-823/824/825 behavior; 0 emits the CHECKDB advisory +IO_CORRUPTIONwarning code (deduped), counts the stat as failed, and continues (@FailFast = 1still aborts regardless). TOCTOU CATCH skips (208/15009/2767) now close their pre-inserted CommandLog row (EndTime +TOCTOU_SKIPmessage; ErrorNumber intentionally NULL so diag failure aggregations ignore them) instead of leaving it dangling like a killed stat. (3)@QueryStore = WAITScategory fix (gh-544): the wait filter usedwait_category IN (3, 12, 15)= Lock/Preemptive/Network IO — classes statistics updates cannot influence — while its comment claimed Buffer IO/Memory/SortAndTempDb. Corrected to5, 6, 17, 21(Buffer Latch, Buffer IO, Memory, Other Disk IO) and centralized into one documented@qs_wait_categoriesvariable so the three consumer sites can no longer drift. - 3.5.8.2026.06.12 - Telemetry bug fix: four mid-run early returns (
NO_QUALIFYING_STATS,FINGERPRINT_CONFLICT,MAX_WORKERS,QUEUE_INIT_ERROR) exited after theSP_STATUPDATE_STARTinsert without ever writing anSP_STATUPDATE_ENDrow. Healthy runs were orphaned: the diag's C1 check raised false KILLED criticals, orphan cleanup later fabricated END rows withStopReason=KILLED(permanently recording healthy no-op runs as kills), and the RELIABILITY grade was poisoned. All four paths now write a real END row with full Summary XML and the true StopReason. Also fixed: theFINGERPRINT_CONFLICTrefusal was dead code -- its severity-16 RAISERROR fired inside a TRY whose CATCH was empty, so the documented refuse-the-join semantics (RETURN 50001, ERROR result set) never executed and conflicting workers silently joined the queue anyway. The conflict now flags inside the TRY and exits via a label outside all TRY scopes, so the error surfaces to the caller AND the bookkeeping runs (sp_StatUpdate-rse2). - Diag 2026.06.12.1 - Silent no-op parallel run detection, validated against the v31y zombie-queue incident signature. New CRITICAL check C6 SILENT_NOOP_RUN: flags runs that self-report normal completion (
PARALLEL_COMPLETE/COMPLETED) with StatsFound > 0 but zero stats processed across the worker window -- no overlapping worker processed anything and noUPDATE_STATISTICSactivity exists in the window. W3 STALE_BACKLOG is un-suppressed when the C6 signature is present, which also closes the COMPLETION grade-A fast path for zombie joiners reporting StatsRemaining = 0.CompletionPctno longer trustsPARALLEL_COMPLETEblindly: with StatsRemaining > 0 it cross-checks the worker group (overlapping parallel runs) and scores SUM(processed)/MAX(found) instead of a constant 100. Genuinely-drained fleets and healthy late joiners keep their 100% (no 2026.05.28.1/3j5l regression) (sp_StatUpdate-kzx0). - 3.5.7.2026.06.12 - Parallel bug fix: queue leadership re-claim no longer blocked by recycled SPIDs or idle pooled leader sessions. The claim UPDATE judged leader/worker liveness by bare
session_idinsys.dm_exec_sessions, so once a drained queue's old SessionID was reused by any live session, every new worker with the same@parameters_stringjoined the completed queue, claimed nothing, and exitedPARALLEL_COMPLETEwith StatsFound > 0 but StatsProcessed = 0 -- a silent no-op maintenance run. Liveness is now login_time-qualified (a genuine claimer logged in before claiming; a recycled SPID logged in after), same-SPID rows are unconditionally stale, a fully drained queue is re-claimable regardless of leader session state, and the active-worker guard is scoped to open claims (TableEndTime IS NULL). Found by the ConcurrentParallel P3 gate test on a fresh container (sp_StatUpdate-v31y). - 3.5.6.2026.06.10 - Code-review batch. (1) Parallel fingerprint conflict check hoisted out of the queue-creator branch so queue JOINERS are validated too -- previously a worker that found an existing queue bypassed the
@stored_fingerprintcomparison entirely, letting fingerprint-only params (critical-table, mop-up) differ silently within one queue (sp_StatUpdate-r77t). (2) CONTEXT_INFO restored on all 7 early-return paths so pooled sessions no longer keep a stalesp_StatUpdate|...tag after early exits (sp_StatUpdate-orec). (3) LOW batch: dead/StatInfoXPath limb removed from CommandLog readers,@FirstTimeFullScanCapRowsnow also intercepts the RESAMPLE-of-persisted-100 path, re-entrancy guard comment corrected (sp_StatUpdate-mc03). Test gate expanded to 6 suites (parallel-mode + partitioned-incremental promoted); diag suite de-tautologized; V3Fixes rewritten to behavioral assertions (sp_StatUpdate-8gzw). - Diag 2026.06.10.1 - @Help checks catalog completed with the 10 emitted-but-undocumented checks: W7 HIGH_IMPACT_STATS_DEPRIORITIZED, W8 MOPUP_INEFFECTIVE, W9 LOCK_TIMEOUT_INEFFECTIVE, W10 PARAMETER_CHURN, C5 SAMPLE_RATE_DEGRADATION, I9 WORKLOAD_CONCENTRATION, I11 FAILURE_CLUSTERING, I12 QS_COVERAGE_DRIFT, I13 PARALLEL_OPPORTUNITY, I14 MOPUP_MISSING_PAGECOUNT. No behavior change to the checks themselves (sp_StatUpdate-8gzw).
- 3.5.5.2026.06.08 - Two fixes: (1) false
ALREADY_RUNNINGon consecutive same-session calls after a validation-error exit. Caller TRY/CATCH catches the proc's severity-16 validation RAISERROR before cleanup, leaving a stale lock row. Three-part re-entrancy guard: same-SPID is unconditionally stale, idle > 60s without active request is dead, AcquiredAt > 8h is dead (sp_StatUpdate-i7by). (2) Diag registersQS_PARALLEL_UNRELIABLEin the valid-categories catalog as INFO I9b (sp_StatUpdate-g4c0). - Diag 2026.06.08.2 - Four W5 / I10 refinements following v3.5.4 parallel priority fix. W5 now parallel-aware: emits softer INFO
QS_PARALLEL_UNRELIABLEinstead of WARNING when ALL QS runs use@StatsInParallel='Y'but QS CPU data IS observed. I10 W5-conditional: keeps@SortOrder=QUERY_STOREand@QueryStore=CPUwhen W5 fired as parallel artifact, not genuine QS absence. TimeLimit jitter normalization rounds <300s variance to nearest minute for clean recommended values.@CriticalTableshint appended to I10 when W9LOCK_TIMEOUT_INEFFECTIVEhas fired (sp_StatUpdate-6x80). - 3.5.4.2026.06.08 - Two fixes: (1) Parallel TablePriority population had LPT (
COALESCE(est_total_seconds, 0) DESC) as PRIMARY sort, silently overriding user-intent min_priority rank. Swapped ORDER BY so min_priority is PRIMARY and LPT is the tiebreaker -- QS-top stats now land at ProcessingPosition 1-10 instead of 285-296 on mature fleets (sp_StatUpdate-mhje). (2) New@FirstTimeFullScanCapRows bigint = NULLparameter: stats with row count above threshold AND no successful CommandLog entry within retention get FULLSCAN capped to sampled rate using adaptive sampling formula (min 1%). Critical-table override still wins. Addresses _WA_Sys stats on 770M-15.9B row tables with ~3085s FULLSCANs on first encounter (sp_StatUpdate-mknv). - Diag 2026.06.08.1 - Four W5 / I10 refinements following v3.5.4 mhje fix. W5 parallel-aware: when ALL QS-priority runs use
@StatsInParallel='Y'and QS CPU data IS observed in stat updates, emits softer INFOQS_PARALLEL_UNRELIABLEinstead of WARNING. Per-worker ProcessingPosition counters made the original signal unreliable in parallel mode. I10 W5-conditional: only reverts@SortOrder=MODIFICATION_COUNTERand clears@QueryStorewhen W5 fired due to genuine QS absence, not parallel artifact. TimeLimit jitter normalization: when MAX-MIN of TimeLimit across recent non-killed runs is <300s, rounds to nearest minute for clean values like 21600.@CriticalTableshint: appends commented-out suggestion with top 3 contended tables when W9LOCK_TIMEOUT_INEFFECTIVEfires (sp_StatUpdate-6x80). - 3.5.3.2026.0529 - Bug fix:
@total_statswas assigned by a CASE inside a multi-variable DECLARE whose parallel branch reads the optionaldbo.QueueStatistictable. A CASE in a DECLARE is a single always-compiled statement, so SQL Server name-resolved the QueueStatistic branch even on non-parallel runs and failed with Msg 208 when the table was absent.@total_statsis now assigned via IF/ELSE so the reference resolves only in parallel mode where the table exists (sp_StatUpdate-isa2). - 3.5.2.2026.0528 - Bug fix: orphaned-run cleanup now bounds the orphan START candidate hunt to the same window as
#orphan_end_labels, so a long-completed parallel run whose real END predated the window no longer receives a fabricated KILLED end -- which had been falsely flagging healthy runs as killed (sp_StatUpdate-trla). - Diag 2026.05.28.1 - Two parallel-mode false-positive fixes: the run dedup tiebreaker now keeps the real END over a synthetic KILLED end (sp_StatUpdate-772k), and completion percentage recognizes the PARALLEL_COMPLETE stop reason instead of pinning parallel runs at 100/workers % (sp_StatUpdate-3j5l). Together these had produced fleet-wide false RELIABILITY F grades.
- 3.5.1.2026.0507 - Failed UPDATE STATISTICS rows now write ExtendedInfo with RunLabel + full discovery context to CommandLog, so sp_StatUpdate_Diag's C2 (REPEATED_FAILURES) check no longer silently misses failures. TOCTOU rows (errors 208, 15009, 2767) keep NULL ExtendedInfo by design (gh-515).
- 3.5.0.2026.0423 -
@CriticalTablesfeature: per-table sample-rate override with optional priority boost. Three new params --@CriticalTables(comma-delimited patterns with%wildcards),@CriticalSamplePercent(forces sample rate on critical tables only), and@CriticalTablesFirst(processes them before everything else regardless of@SortOrder). PERSIST_SAMPLE_PERCENT auto-enabled for critical tables (gh-508). - 3.4.1.2026.0423 -
@LockTimeoutpromoted to a public parameter, overriding preset@i_lock_timeout(-1 = wait forever, 0 = no wait, N > 0 = seconds). Addresses Error 1222 lock-timeout failures (gh-508). - 3.4.0.2026.0422 - CommandLog intelligence: Phase 3B qualifies on ModificationCounter delta instead of the raw counter; delta=0 stats skip qualification. New MODIFICATION_VELOCITY sort order ranks by mods/hour. Phase 5B caches QS scores from CommandLog so Phase 6 skips expensive QS DMV joins for stats with fresh cached scores (gh-502, 503, 507).
- 3.3.5.2026.0422 - QS forced plan check replaces the CHARINDEX scan with a sql_expression_dependencies integer join for compiled objects (ad-hoc fallback retained). Parallel lead-worker gate prevents N-worker redundancy. Parallel progress now shows the global QueueStatistic total as denominator + GlobalStatsDone in ExtendedInfo (gh-498, 499, 500).
- 3.3.4.2026.0420 - Bug fix: removed the server-level AG-secondary hard-error from parallel pre-flight that regressed v2 behavior of silently skipping unreadable AG databases and proceeding against the remainder. Kept the orphan QueueStatistic backlog warning (gh-428 follow-up).
- 3.3.1.2026.0420 - Deploy fix: a pre-ALTER-PROCEDURE migration adds
ClaimLoginTime+LastStatCompletedAtto an existingdbo.QueueStatistic, so upgrades from v2.26 or earlier no longer fail compilation with "Invalid column name". Idempotent; no-op on fresh installs. - 3.3.0.2026.0418 - Additive enhancements (5 issues):
@JobNamecontext tag written to CONTEXT_INFO (gh-423),@DeadWorkerTimeoutMinutesNULL coercion + stale-row sweep (gh-425),@WarningsCodesOutOUTPUT param with stable code tokens (gh-426),@SkipTablesWithNCCI/@SkipTablesWithCCIper-type columnstore controls (gh-427), and parallel pre-flight validation (gh-428). - 3.2.2.2026.0417 - Internal refactors (behavior-preserving):
@parameters_stringbuilt once in region 07B (gh-461), mop-up WHERE filter extracted to@mop_up_where_sql(gh-462), and the empty-schema SELECT extracted to@empty_disc_selectfor all 6 staged-discovery bailout paths (gh-465). - 3.2.1.2026.0417 - Phase 5 bug fix: DIRECT_STRING discovery path (
@Statisticsparam) now honors@TargetNorecomputeand@ExcludeStatisticsfilters, matching staged discovery Phase 1 behavior (gh-492). - 3.2.2026.0417 - Phase 4 quality/perf batch (8 issues): Phase 6 plan feedback bounded by time + top-N, 5 COUNT_BIG scans collapsed to one SUM(CASE), orphan cleanup materializes END labels, per-table sys.partitions cache, per-database warning block skipped when DB has zero stats, MAX_GRANT_PERCENT token-substituted, 6 empty CATCH blocks surface to
@WarningsOut, threshold-logic explanation gated behind@Debug = 1(gh-460, 463, 464, 466-470). - 3.1.2026.0417 - Phase 1 correctness batch (9 issues): parallel early-return paths set OUTPUT params + summary result set, LOCK_TIMEOUT restored after forced-plan check,
@QueryStore = AVG_CPUsorts by average (not total),@parameter_fingerprint+ additional correctness fixes (gh-451..459). Also Phase 2/3 diag correctness (10 issues) in sp_StatUpdate_Diag 2026.04.17.1 (gh-471..480). - 3.0.2026.0407 - v3 architecture: preset-first API. 33 input params (was 58 in v2) + 10 OUTPUT params. 25 params absorbed into
@i_internal variables controlled by presets (DEFAULT, NIGHTLY, WEEKLY_FULL, OLTP_LIGHT, WAREHOUSE). New@QueryStoreparam replaces@QueryStorePriority+@QueryStoreMetric.@StaleHoursreplaces@DaysStaleThreshold+@HoursStaleThreshold. Table-driven validation, 6-phase staged discovery only (no legacy fallback), unified mop-up filters. Full behavioral parity with v2.37. - 2.37.2026.0327 - WAITS enrichment unbounded XML parsing fix (metric gate + TopPlans limit), Phase 6 debug gate.
- 2.35.2026.0327 - @QueryStoreMetric WAITS + diag memory grant trending.
- 2.34.2026.0326 - QS discovery metric gaps: 6 issues resolved.
- 2.29.2026.0325 - Em dashes, AG sync-only redo, mop-up safety + 8 new diag checks (W8-W10, C5, I11-I14).
- 2.24.2026.0324 - Staged discovery hardening, legacy QS consistency, @MopUpPass, @MopUpMinRemainingSeconds.
- 2.23.2026.0324 - @QueryStoreTopPlans (Phase 6 XML parsing limit), early bail-out.
- 2.22.2026.0320 - AscendingKeyBoost, CE QUERY_OPTIMIZER_HOTFIXES, APC awareness, cursor-to-set-based.
- Diag 2026.03.23.1 - I10 RECOMMENDED_CONFIG, @ExpertMode, Executive Dashboard A-F grades, persistent history.
- 2.16.2026.0308 - QS Efficacy Trending, ProcessingPosition, diag RS 9-13, I6/I7/I8.
- 2.14.2026.0304 - Bulk issue resolution (42 issues). @OrphanedRunThresholdHours. AG/safety guards.
- 2.8.2026.0302 - Comprehensive sweep (31 issues). @IncludeIndexedViews, @LogSkippedToCommandLog, QS forced plan warning.
- 2.7.2026.0302 - AG redo queue pause, tempdb pressure check.
- 2.4.2026.0302 - Region markers, collation-aware comparisons, per-phase timing, 12-issue bug-fix sprint.
- 2.0.2026.0212 - Environment Intelligence, staged discovery, diagnostic tool (sp_StatUpdate_Diag).
- 1.9.2026.0206 - Status/StatusMessage columns, batch QS enrichment.
- 1.5.2026.0120 - CRITICAL: Fixed @ExcludeStatistics filter, incremental partition targeting.
- 1.4.2026.0119 - Query Store prioritization, filtered stats handling.
- 1.3.2026.0119 - Multi-database support, OUTPUT parameters, return codes.
- 1.0.2026.0117 - Initial public release.
IndexOptimize is battle-tested and handles indexes + stats together. Use it for general maintenance.
sp_StatUpdate is for when you need:
- Priority ordering (worst stats first)
- Time-limited runs with graceful stops
- NORECOMPUTE targeting
- Query Store-driven prioritization (10 metrics, tunable plan parsing)
- Adaptive sampling for problematic stats
- AG-safe maintenance with redo queue awareness
- Mop-up pass for thorough coverage
- Programmatic access to results via OUTPUT parameters
MIT License - see LICENSE for details.
Based on patterns from Ola Hallengren's SQL Server Maintenance Solution (MIT License).
- Ola Hallengren - sp_StatUpdate wouldn't exist without his SQL Server Maintenance Solution. We use his CommandLog table, Queue patterns, and database selection syntax. If you're not already using his tools, start there.
- Brent Ozar - years of emphasizing stats over index rebuilds, First Responder Kit, and community education.
- Erik Darling - T-SQL coding style and performance insights. His diagnostic tools are excellent - I'm particularly fond of sp_LogHunter and sp_QuickieStore.
- Tiger Team's AdaptiveIndexDefrag - the 5-tier adaptive threshold formula
- Colleen Morrow - parallel statistics maintenance concept



