Skip to content

IndexOptimize: add @MinIndexUsageDays to skip unused non-clustered indexes when DMV history is sufficient - #1015

Open
forward-thinkers-lab wants to merge 2 commits into
olahallengren:mainfrom
forward-thinkers-lab:feature/min-index-usage-days
Open

IndexOptimize: add @MinIndexUsageDays to skip unused non-clustered indexes when DMV history is sufficient#1015
forward-thinkers-lab wants to merge 2 commits into
olahallengren:mainfrom
forward-thinkers-lab:feature/min-index-usage-days

Conversation

@forward-thinkers-lab

Copy link
Copy Markdown

Summary

Adds a single optional parameter, @MinIndexUsageDays, that lets dbo.IndexOptimize skip non-clustered indexes with no recorded reads in sys.dm_db_index_usage_stats — but only when the DMV has been collecting data long enough on the current instance for the "no reads" signal to be meaningful.

Default is NULL (feature off). Existing behavior is preserved bit-for-bit.

New parameter

Parameter Type Default Purpose
@MinIndexUsageDays int NULL Minimum number of days of DMV history required before an index is allowed to be classified as unused and skipped.

When set:

  • Non-clustered indexes with user_seeks + user_scans + user_lookups = 0 are deselected for this run only if the DMV for that database is judged old enough.
  • Otherwise the unused-index filter is bypassed for that database and fragmentation-based maintenance proceeds as today.
  • Clustered indexes and heaps are never skipped.

Why

sys.dm_db_index_usage_stats is volatile. Its rows are reset or invalidated by:

  • service restart
  • FCI / AG / mirroring failover
  • detach/attach, restore, new database (new database_id)
  • index drop/recreate

Reading the DMV without accounting for how long it has been accumulating produces false negatives — an index can look unused simply because the DMV is too young. Skipping (or, in other tools, dropping) on that basis is unsafe.

This change derives a per-database lower bound on DMV age and gates the unused-index logic on it.

How DMV age is estimated

SQL Server does not expose a "last reset" time for the DMV, so the script computes a conservative lower bound per database as the MAX of three signals:

  1. sys.dm_os_sys_info.sqlserver_start_time — instance uptime.
  2. sys.databases.create_date — covers attach/restore/new DB.
  3. MIN(last_user_seek, last_user_scan, last_user_lookup, last_user_update) for the database — earliest observed activity in the DMV.

The DMV is considered trusted when DATEDIFF(HOUR, estimate, SYSDATETIME()) >= @MinIndexUsageDays * 24.

How each reset scenario is covered:

Scenario Signal that catches it
Service restart (1) — sqlserver_start_time is fresh
FCI failover (1) — service started on the other node
AG failover (3) — DMV on the new primary only contains post-failover activity; trust check is also skipped on AG secondaries
Mirroring failover (3) — same mechanism as AG
Detach / attach (2) — refreshed create_date
Restore (2) — refreshed create_date
New database (2)
Index drop/recreate (3) at DB level + per-index counters reset intrinsically
External tooling clearing DMV rows (3) — MIN(last_user_*) advances to the clear time

The trust threshold is derived from @MinIndexUsageDays and is intentionally not exposed as a separate parameter, so it can never be set looser than the usage rule it protects.

Contexts where the trust check is skipped

  • Database is not ONLINE.
  • Database is on an AG secondary, or AG role cannot be determined.
  • Amazon RDS rdsadmin.

In these cases the procedure behaves as today (no unused-index skipping for that database).

Backward compatibility

  • Default NULL preserves existing behavior exactly.
  • No existing parameters renamed, removed, or repurposed.
  • No new dependencies; only DMVs already used elsewhere in the procedure or available since SQL Server 2008.
  • Targeting SQL Server 2008 through 2025, all editions; Azure SQL DB / MI; Amazon RDS.

Testing

All tests run on dedicated test instances; results verified via procedure output and dbo.CommandLog.

  1. Default parametersEXEC dbo.IndexOptimize @Databases = 'USER_DATABASES'. Output and CommandLog identical to the prior version.
  2. Trust granted — instance up several days, @MinIndexUsageDays = 1. Non-clustered indexes with zero reads correctly deselected.
  3. Trust denied — service restarted, @MinIndexUsageDays = 30. Unused-index filter bypassed; fragmentation maintenance still runs; output explains DMV age vs. required age.
  4. Input validation@MinIndexUsageDays = -1 raises an error via the existing @Errors mechanism.
  5. Clustered indexes never skipped — confirmed by the IndexID > 1 predicate on the deselection update; verified on a clustered index with no recent reads.
  6. AG secondary — trust block correctly skipped, no DMV reads for the unused-index logic.
  7. Multi-version — repeated tests 1–3 on SQL Server 2016, 2019, 2022; behavior consistent.

Ysaias Portes added 2 commits May 20, 2026 09:18
Adds a single user-facing parameter that skips apparently-unused
non-clustered indexes, but only when sys.dm_db_index_usage_stats
has accumulated enough history to be trusted (>= @MinIndexUsageDays).

Trust is derived internally from MAX(sqlserver_start_time,
database create_date, earliest DMV activity); the user cannot
override it. When the DMV is too young, the script logs a clear
diagnostic and falls back to fragmentation-only behavior.

Backward compatible: default NULL preserves existing behavior.
The trust-check block was inserted before @CurrentAvailabilityGroup
and @CurrentAvailabilityGroupRole are populated, so the guard
condition evaluated NULL IS NOT NULL = FALSE and the block ran on
AG secondaries — exactly the case it was meant to skip.

Move the block to immediately before the @ExecuteAsUser check,
after the AG and mirroring role discovery and their RAISERROR
messages, so the guard sees real values.

No change to the block's contents.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants