Skip to content

Updates_20260701#814

Merged
erikdarlingdata merged 33 commits into
mainfrom
dev
Jul 4, 2026
Merged

Updates_20260701#814
erikdarlingdata merged 33 commits into
mainfrom
dev

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Updates_20260701

Release merge to main.

  • @version_date bumped to 20260701 on all 10 combined-install procs (minor versions unchanged).
  • SET ANSI_NULLS ON; + SET ANSI_PADDING ON; header parity fix for sp_IndexCleanup, sp_PerfCheck, sp_QuickieCache (behaviorally inert — no = NULL comparisons).
  • Install-All/DarlingData.sql intentionally left untouched — the Format-and-Build CI job regenerates it on main.
  • Compile-tested on SQL 2016 / 2017 / 2019 / 2022 / 2025 — all pass (CS_AS collation).

Folds in all dev work since Updates_20260601: sp_HumanEvents (executions fix, CATCH hardening), sp_HumanEventsBlockViewer (non-lock/self detection, wait_resource object resolution), sp_IndexCleanup (filtered-index fix script, compression counts), sp_PressureDetector (version-store cleanup blockers), sp_QueryReproBuilder (include-filter intersect, hash/handle filters), sp_QuickieCache (clickable query-text XML).

🤖 Generated with Claude Code

kendra-little and others added 30 commits May 22, 2026 13:38
Adds "event_time" as a valid value for @query_sort_order so query
results can be ordered newest-first by capture time. event_time is
converted to a bigint via DATEDIFF_BIG so it shares the numeric
result type of the existing CASE branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New @skip_execution_plans bit parameter (default 0) lets callers bypass
the execution plans result set and jump straight to the findings rollup.

- Main path: GOTO BlockingRollup skips #available_plans gathering, the
  sys.dm_exec_text_query_plan calls, and the plan result set.
- system_health path: RETURNs after the blocking results (no rollup there).
- Added to @help across description, valid inputs, and defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sp_HumanEvents: allow sorting query results by event_time
- Widen @query_sort_order from nvarchar(10) to nvarchar(20) so longer
  values like "avg duration" (12 chars) no longer silently truncate.
- Reformat the event_time DATEDIFF_BIG in the ORDER BY CASE to the
  multi-line multi-arg function style used elsewhere in the proc.
- Reword @help text and README so "avg" clearly applies only to the
  metric list, with "event_time" listed as a separate option.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #filtered_objects population gated every table behind an EXISTS against
sys.dm_db_index_usage_stats. That DMV only has a row for an index touched since
the last stats reset, and Azure SQL DB / Hyperscale reset it on every failover
and scaling operation. With the DMV empty, no table passed the EXISTS even at
the default @min_reads = 0 / @min_writes = 0, so #filtered_objects came back
empty, @rc = 0, and the proc hit a bare RETURN whose only message was
@debug-gated at severity 10 -- exiting with no result set and no error.

Fixes:
1. Only append the index-usage EXISTS when @min_reads > 0 OR @min_writes > 0.
   At the defaults, usage is no longer consulted for filtering, so an empty or
   reset DMV cannot empty the result and never-used indexes are analyzed (the
   point of the tool). Also fixes freshly-restarted on-prem instances.
2. Replace the silent single-database RETURN with a fall-through so an empty
   database surfaces in the existing 'DATABASES WITH NO QUALIFYING OBJECTS'
   result instead of returning nothing.

Validated against fresh Azure SQL Database and Hyperscale instances with
dm_db_index_usage_stats reset to empty: old proc returned nothing/no error,
fixed proc correctly returned results and flagged duplicate indexes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…usage-stats-early-exit

sp_IndexCleanup: fix silent early exit on Azure SQL DB/Hyperscale when index usage stats are empty
The totals CTE in both HumanEvents_Queries view bodies computed
executions as COUNT_BIG(*) OVER (PARTITION BY query_plan_hash_signed,
query_hash_signed, plan_handle) -- the exact same three columns as the
query's GROUP BY. After grouping, every partition is a single row, so
executions was always 1 (issue #802).

Replaced with a plain COUNT_BIG(q.plan_handle) aggregate, matching the
live #totals build path. Applies to both view variants
(@skip_plans = 0 and @skip_plans = 1).

Source proc only; Install-All/DarlingData.sql is auto-generated by
Merge-All.ps1 and is not hand-edited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…executions-802

sp_HumanEvents: fix executions always 1 in HumanEvents_Queries view (#802)
…view

Follow-up to #802. The earlier fix (743e228) replaced the always-1
windowed count with COUNT_BIG(q.plan_handle) in the totals CTE, but in
the @skip_plans = 0 view the query_agg CTE is a UNION ALL of two event
types: sp_statement_completed (the execution rows) and
query_post_execution_showplan (plan/memory-grant rows). plan_handle is
non-NULL in both branches, so COUNT_BIG counted every execution twice --
a query that ran twice reported executions = 4 instead of 2.

Tag each query_agg row with is_execution (1 for the statement-completed
branch, 0 for the showplan branch) and compute
executions = SUM(CONVERT(bigint, q.is_execution)), so only real
executions are counted. Applied to both view variants to keep the totals
CTE identical; in HumanEvents_Queries_np (no showplan branch) it is a
functional no-op but slightly more robust than COUNT_BIG against a NULL
plan_handle.

Verified against SQL Server 2022: a query with 2 statement-completed +
2 showplan rows now reports executions = 2 (was 4) in the with-plans
view and 2 in the _np view, with totals unchanged.

Source proc only; Install-All/DarlingData.sql is auto-generated by
Merge-All.ps1 and is not hand-edited.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…double-count-802

sp_HumanEvents: fix executions double-counted in HumanEvents_Queries view
…mit filtered-index fix script

Reporting numbers diverged between the top line and the detail because the
three summary levels were built from different base tables: SUMMARY rolled up
#index_analysis (analyzed nonclustered indexes) while DATABASE and TABLE rolled
up #partition_stats (every index, including heaps and clustered), with a
per-column join to #index_details double-counting reads and writes.

Rebuild all three levels bottom-up from one fan-out-free spine of analyzed
nonclustered indexes so SUM(TABLE) = DATABASE = SUMMARY by construction:
- TABLE: one row per analyzed table, metrics pre-aggregated per index.
- DATABASE: a SUM of the current database's TABLE rows.
- SUMMARY: moved after the database loop, a SUM of the DATABASE rows. This also
  removes the duplicate top-line rows the old in-loop cumulative insert emitted
  on multi-database runs.

Also:
- Order unused-index recommendations by writes first, then size, then rows, so
  high-write (pure write-amplification) indexes surface ahead of merely large
  ones.
- Add a ready-to-run create_index_script to the FILTERED INDEXES NEEDING
  INCLUDED COLUMNS report (rebuilds the index with the filter columns appended
  to INCLUDE via DROP_EXISTING), and QUOTENAME the missing-column list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ITH options on filtered-index fix

Loosen the summary spine from index_id > 1 to index_id > 0. #index_analysis only
carries clustered indexes that are compression candidates, so this counts every
index the tool examined and can act on (nonclustered dedup candidates plus
clustered compression candidates) instead of nonclustered only. Tables that have
just a clustered index now appear in the report, and compressable_indexes lines
up with the emitted compression scripts (e.g. a clustered-only database that
previously reported 0 tables / 0 compressable while emitting compression scripts
now reports them). All three levels still reconcile: SUM(TABLE) = DATABASE = SUMMARY.

Carry the standard WITH options through the filtered-index fix script so it matches
the merge/create scripts: DROP_EXISTING, FILLFACTOR, SORT_IN_TEMPDB, edition-aware
ONLINE, DATA_COMPRESSION when eligible, plus the filegroup/partition-scheme ON
clause (built_on) so the rebuilt index stays on its original storage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cheme aware

The original_index_definition reference statement stopped at the WHERE clause, so
using it to re-create an index (e.g. to reverse a DISABLE) would land the index on
PRIMARY instead of its real filegroup. Append the storage clause built from
#partition_stats.built_on (ISNULL(partition_scheme_name, filegroup_name)) in both
build sites - the nonclustered/dedup build and the clustered/PK build - so the
statement round-trips faithfully:

  ... ON [SomeFilegroup];
  ... ON [SomePartitionScheme](PartitioningColumn);

Verified against partitioned objects: PRIMARY KEY CLUSTERED constraints and
CREATE [UNIQUE] CLUSTERED/NONCLUSTERED INDEX statements now carry the correct
ON clause, and SUM(TABLE) = DATABASE = SUMMARY still reconciles (the added joins
are 1:1 per index, no fan-out).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…reporting-improvements

sp_IndexCleanup: reconcile reporting levels, sort unused by writes, filegroup-aware fix scripts
…807)

When @event_type = 'blocking' is run while 'blocked process threshold'
is 0, the existing guard raises a severity-11 message telling the user
to configure the blocked process report. Because that RAISERROR runs
inside the procedure-wide TRY, control transfers to the CATCH, which
unconditionally ran ALTER EVENT SESSION ... STATE = STOP against a
session that was never created -- throwing Msg 15151 and masking the
real, helpful message.

Guard the CATCH cleanup so it only stops/drops the session when it
actually exists, using the proc's existing @azure-branched
sys.server_event_sessions / sys.database_event_sessions existence
checks. This restores the helpful message for the blocking case and
for every other pre-session-creation validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Copilot review on #808:
- Use SELECT 1 instead of SELECT 1/0 in the EXISTS checks.
- Wrap the session-existence check in its own TRY/CATCH that defaults
  @session_exists = 0, so if the catalog lookup itself fails (e.g. a
  missing permission on the event-session catalog views) it can't
  re-mask the original error before the final THROW.
- Collapse each @Azure branch to a single CASE/EXISTS assignment; the
  branch itself stays because the server- vs database-scoped catalog
  view differs (and sys.database_event_sessions can't be referenced on
  pre-2016 on-box SQL Server).

Re-validated on SQL Server 2025 (17.0.4045.5): threshold 0 returns the
configuration instructions, threshold 5 runs and self-cleans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
sp_HumanEvents: stop CATCH cleanup from masking pre-creation errors (blocking + blocked process threshold = 0)
…hecks

PR #808 changed SELECT 1/0 to SELECT 1 in the two EXISTS checks on a
reviewer suggestion, but the SELECT list inside EXISTS is never evaluated,
so there is no divide-by-zero risk. 1/0 is the house style for EXISTS in
this codebase; revert both checks to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…th counts

Render query text as a clickable processing-instruction XML column
(matching sp_QuickieStore) in the main result set and in the
@find_single_use_plans / @find_duplicate_plans modes.

Re-source the plan cache health rollup so its numbers reconcile with the
actual plan cache:
- Single-use plan bloat now reads sys.dm_exec_cached_plans (compiled
  plans, usecounts = 1, Adhoc/Prepared) instead of statement-grained
  sys.dm_exec_query_stats, which produced misleadingly tiny per-database
  counts (e.g. "27 of 27 plans" on a 100k+ plan cache).
- Plan cache instability/stability and duplicate-plan findings now count
  at plan grain (COUNT_BIG(DISTINCT plan_handle)) and report the true
  cached compiled plan total, with consistent system-database filters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rollup

The check_id 9 "Top Lead Blocker" finding dumped the raw inputbuf into
object_name, so a blocker running a stored procedure showed the literal
"Proc [Database Id = N Object Id = M]" marker instead of the procedure
name. Apply the same OBJECT_SCHEMA_NAME/OBJECT_NAME resolution the detail
output already uses, falling back to the raw inputbuf for ad hoc
statements or objects that can't be resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TIAL_KEY consistently

- Compression candidates now sort largest-first by size in default mode
  instead of writes-first, so the biggest space wins surface at the top.
- OPTIMIZE_FOR_SEQUENTIAL_KEY now follows through to every generated
  rebuild/recreate script. The MERGE, filter-rebuild, and KEPT-record
  compression scripts previously omitted it; on the CREATE INDEX ...
  DROP_EXISTING forms that silently reset the option to OFF. FILLFACTOR =
  100 stays intentional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r-indexcleanup

Fixes: sp_QuickieCache, sp_HumanEventsBlockViewer, sp_IndexCleanup
Fix include-filter widening: @include_plan_ids and @include_query_ids both
fed a single #include_plan_ids table checked by one EXISTS, so they combined
with OR. Passing a specific plan id alongside its query id re-added every
sibling plan, erasing the plan-id filter. Include filters now intersect
(AND across filters, OR within a list) via a seed-or-intersect model: the
first supplied filter seeds the set, each later filter deletes non-matching
plans. Ignore filters keep union semantics (correct for exclusion).

Port identifier filters from sp_QuickieStore: @include/@ignore_query_hashes,
_plan_hashes, and _sql_handles (hex parsed to binary(8)/varbinary(64)).
Wires through params, NULLIF normalization, temp tables, resolution, final
WHERE gates, @help, and the README. All filter resolution stays gated under
@query_plan_xml IS NULL so direct-XML mode is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…clude-filters

sp_QueryReproBuilder: intersect include filters; add hash/handle filters
… output

Adds a version_store_blockers element to the tempdb XML, listing the
oldest active row-versioning transactions (RCSI/snapshot) that hold back
instance-wide version store cleanup, oldest first. One such transaction
in any database freezes cleanup everywhere, which is what inflates the
adjacent version_store_gb. Surfaces session, host/login/program,
isolation level, transaction age, XSN, idle time, and last command so
the cause is visible right next to the symptom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rollup (#806)

The blocked process monitor fires for any task waiting longer than the
configured blocked process threshold, not only lock waits. Non-lock waits
(memory grants, parallelism, etc.) and self-referential reports (a session
reported as blocking itself) therefore surface as blocked process reports,
but were counted alongside genuine lock contention in the findings rollup
with no way to tell them apart.

Add finding check_id 10 ("Non-Lock and Self Blocking") that counts these
reports per database, filtered on resource_owner_type <> 'LOCK'. LOCK is the
only lock value in the resource_owner_type XE map; every other value is a
non-lock wait, and a self-referential report is always non-lock. Counts
distinct events via event_time since transaction_id is unreliable (often 0)
for non-lock waits and each report yields both a blocking and blocked row.

The reports already flow through the main result set; this only adds the
rollup signal. Existing lock findings and @version/@version_date are
unchanged.

Validated on SQL 2017 and SQL 2025 (case-sensitive collation): the new
finding fires for a non-lock self-wait and excludes a normal lock block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Checks 1 (Database Locks) and 2 (Object Locks) counted every #blocks row,
so non-lock and self-referential reports inflated them alongside genuine
lock contention. Filter both to resource_owner_type = 'LOCK' (or NULL, to
keep unknown-type rows rather than silently drop them) so they report real
lock contention only; the non-lock reports remain counted in check_id 10.

Wait-time totals (check_id 1000/1001) and Login, App, and Host (check_id 8)
are intentionally left inclusive: a non-lock wait is still real wait time
and real activity.

Validated on SQL 2017 and SQL 2025 (case-sensitive collation): with one
lock and one non-lock event staged, checks 1 and 2 now read 1 (was 2),
check 10 reads 1, and the wait-time totals stay at the combined value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…self-806

Surface non-lock and self-blocking reports in sp_HumanEventsBlockViewer rollup (#806)
…ource (#812)

contentious_object was ~99% "Unresolved: ... object_id: N" because it resolved
OBJECT_NAME() against the blocked_process_report event's object_id field, which is
unreliable for KEY/PAGE/RID lock waits (it reports 0 or a non-object id). Decode the
lock resource string instead:
  KEY      -> hobt_id via [db].sys.partitions -> object_id (per contended database)
  OBJECT   -> object_id directly
  PAGE/RID -> sys.dm_db_page_info (2019+, VIEW DATABASE STATE) -> object_id

Non-object locks (DATABASE / XACT / METADATA / ...) and anything that can't resolve
keep the "Unresolved" sentinel the findings + output logic depends on. Resolved values
stay plain schema.object so the @object_name filter still matches. The event object_id
is kept as a COALESCE fallback so nothing that resolved before regresses.

Adds @product_version (PARSENAME of ProductVersion, matching the QuickieStore idiom) to
gate dm_db_page_info; cross-db lookups are per-database dynamic SQL (QUOTENAME'd,
parameterized via sp_executesql), guarded by DB-online checks and TRY/CATCH for the
page-info permission. Compound waitresources ("XACT: ... KEY: ...") resolve off the
embedded KEY/PAGE token. TRY_CONVERT makes the string parse unthrowable.

Validated on SQL Server 2025 (HammerDB workload): contentious_object resolution ~0.3%
-> ~97% on the full history; end-to-end through the proc, KEY locks resolve to
dbo.district / new_order / order_line / customer, PAGE locks that can't resolve are
labeled honestly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Bump @version_date to 20260701 across all 10 combined-install procs (minors unchanged)
- Restore SET ANSI_NULLS ON + SET ANSI_PADDING ON to sp_IndexCleanup, sp_PerfCheck, sp_QuickieCache headers (parity with the other 7 procs; inert - no NULL comparisons)
- Regenerate Install-All/DarlingData.sql

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
# Conflicts:
#	Install-All/DarlingData.sql
…his file is owned by the Format-and-Build CI job and must not be hand-edited
@erikdarlingdata erikdarlingdata merged commit 66a00d1 into main Jul 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants