Repository navigation
sp_IndexCleanup: judge unused indexes on each database's own usage window (#907) - #925
Conversation
…ndow Bug: the unused index guard looked only at server uptime. Index usage also starts over when a database is restored or created, so on a server up for weeks, a LargeDB restored a few days earlier reported hundreds of indexes as unused with no warning. Reads on the readable secondaries of an availability group primary are not counted either, and nothing said so. Repro: on a server up more than 14 days, restore SmallDB under a new name or over itself, use it lightly, run sp_IndexCleanup @database_name = N'SmallDB'. Fix (issue 907): - Each database gets usage_since: the latest of the server start, its last restore in msdb.dbo.restorehistory and its create_date. The DATABASE row of the summary shows it with usage_since_reason; other levels leave both NULL. The restore lookup is skipped on Azure SQL DB and falls back to create_date when msdb cannot be read. - Rule 1 is skipped per database when that window is 7 days or less, and the DATABASE row says so. The global switch for a server up 7 days or less, and an explicit @dedupe_only = 1, are unchanged. - The Unused Index warning label follows the database window. When the window is the server start the label text is unchanged. - On an availability group primary with a readable secondary, the DATABASE row notes that reads on the secondaries are not counted. Before: SmallDB restored 3 days ago on a server up 30 days gave Unused Index rows with no warning. After: its DATABASE row reads "Database restore. Unused Index rule skipped for this database: only 3 days of usage data (7 days or less)", it has no Unused rows, and other databases in the run are unchanged. Tests: new Group A-Window in rule_coverage_test.py (22 assertions) using a scratch copy of the procedure with the server start pinned 12 days back and a real BACKUP and RESTORE of scratch databases. Counts in tests/README.md are now 58/30/133/4 (130 for rule_coverage on a server up more than 7 days). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UT4xePKcEVUmyzwGYSEuqU
InstanceDefaultBackupPath is NULL before SQL Server 2019, so the Group A-Window backup path was NULL there and its BACKUP failed. Fall back to InstanceDefaultDataPath, and drop its trailing separator so the path is built the same way on every version. rule_coverage_test.py against SQL2017: 133 passed, 0 failed, 133 total. The log has no [FAIL] or [SKIP] lines, and 22 window-related checks passed. No ic_win% databases were left behind. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UT4xePKcEVUmyzwGYSEuqU
|
Claude finished @erikdarlingdata's task in 32s —— View job Review of PR #925I found no blocking problems. I read the diff but did not run any of it, including the tests.
Minor, optional notes:
The AG path (item 4) is code-read only, as the PR says. The rest looks good to me. |
|
Claude finished @erikdarlingdata's task in 26s —— View job Review of PR #925The change looks sound. I read the What checks out
Minor points, none blocking
The README replacement text in the PR description is clear. Apply it at release time. |
|
Claude finished @erikdarlingdata's task in 16s —— View job Review of PR #925 (re-run on synchronize)No new blocking problems. The merge of My earlier findings still stand:
Optional points from the earlier reviews, none blocking:
If you wanted something specific from this |
Bug: daily_write_ops_saved, daily_lock_waits_saved and daily_latch_waits_saved on TABLE rows divided the table's counters by server uptime. Usage and operational counters restart when a database is restored or created, so a database younger than the server got its per-day rates understated. Repro: a database created 10 days ago on a server up 12 days, with 240 writes on a table where one of two indexes is unused. The TABLE row showed 10 (240 / 12 * 0.5) instead of 12 (240 / 10 * 0.5). Fix: the TABLE branch of the final output LEFT JOINs #databases by name (its primary key, so no row multiplication) and divides by ISNULL(d.usage_window_days, @server_uptime_days). The NULLIF(..., 0) is kept. Where the window is the server start, usage_window_days is the same DATEDIFF as server uptime, so the result is identical to before. @server_uptime_days stays for the header text. SUMMARY and DATABASE rows still show N/A. Chose the #databases join over carrying the window onto #index_reporting_stats: it is one join and reads the value the Unused Index rule used. Test: Group A-Window gives the scratch fixture 120 updates touching both indexes and asserts ic_win_mid's TABLE row uses the 10 day window, with a check that the 10 and 12 day answers differ. README counts: 134 and 131. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UT4xePKcEVUmyzwGYSEuqU
|
Claude finished @erikdarlingdata's task in 16s —— View job Review of PR #925 (re-run on synchronize)No blocking problems. The new commit is 9a2ec08, which makes the TABLE per-day rates divide by the database usage window. I read that diff. I did not run any tests.
Optional points from the earlier reviews still apply:
|
Claude posting for Erik Darling.
Closes #907
sp_IndexCleanup now judges unused indexes on each database's own usage window instead of the server uptime alone. All four items under "Additional Context" in the issue are done as written.
What changed (sp_IndexCleanup/sp_IndexCleanup.sql)
usage_sinceandusage_since_reason, on theDATABASErow of the summary.usage_sinceis the latest of the server start, the database's last restore (MAX(restore_date)inmsdb.dbo.restorehistoryfor thatdestination_database_name) andsys.databases.create_date. The reason isServer start,Database restoreorDatabase create. TheANALYZED OBJECT DETAILSandTABLErows output NULL for both. They sit at the end of the visible columns, so existing column positions do not move.Unused Index (WARNING: ...)label follows the database window (< 14 days). When the window is the server start the label is byte-identical to today. When a restore or create set the window, it readsUnused Index (WARNING: Database usage window < 14 days - usage data may be incomplete).@dedupe_only = 0, a database whose window is 7 days or less skips Rule 1, and itsDATABASErow says:Database restore. Unused Index rule skipped for this database: only N days of usage data (7 days or less). Other databases in the run are judged normally. A server up 7 days or less still trips the existing global switch and message, and an explicit@dedupe_only = 1still skips Rule 1 everywhere. Those two cases add no per-database note.DATABASErow's reason gets. Availability group primary with readable secondaries: reads on the secondaries are not counted. Detection follows the existing AG pattern (behind@is_hadr_enabled, dynamic SQL, TRY/CATCH). A failure adds no note.The server uptime stays in the header row and the
ANALYZED OBJECT DETAILSrow. The per-day rate columns on TABLE rows now divide by the database window (see the last section).Engine gates: the restore lookup is skipped on Azure SQL Database (no
msdb.dbo.restorehistory) and runs as dynamic SQL in TRY/CATCH, so a login that cannot read it falls back tocreate_datewithout failing the run.Measured
create_date? It depends on the form. RESTORE under a new name sets the new database'screate_dateto the restore moment (73 ms after therestorehistoryrow). RESTORE ... WITH REPLACE over an existing database keeps the originalcreate_date, so onlyrestorehistoryshows that case (the one the issue describes). Because of the first form, acreate_datewithin 60 seconds of the winning time is credited to the restore when there is one, so a new-name restore readsDatabase restore, notDatabase create.db_ownerin a scratch database,VIEW SERVER STATE) can run the proc. WithDENY SELECT ON msdb.dbo.restorehistorythe debug output showsRestore history lookup failed: The SELECT permission was denied ..., the run completes, and theDATABASErow readsDatabase create. Login, user, database and history rows were dropped.#databasesto check that it parses and executes (0 rows); the primary and readable-secondary logic is code-read only.Tests (SQL2022)
run_tests.pyfixture_cases_test.pyrule_coverage_test.pyno_access_test.pyThe proc also installs cleanly on SQL2016 (syntax check only).
New Group
A-Windowinrule_coverage_test.py(22 assertions, runs on any uptime): it installs a scratch copy of the proc assp_IndexCleanup_zz_window(precedent: 3a69179) with the server start pinned 12 days back and each scratch database'screate_datemoved back. It then builds fiveic_win_*databases, backs one up to the default backup directory, and does a real RESTORE under a new name and a RESTORE WITH REPLACE over an existing one. It asserts, per database: the reason (server start, create, restore, restore), the skip note, no Unused rows for the restored, replaced and fresh databases, the original label for the server-start window, the new label for a 10-day database, an Unused row still present for the old database in the same run, NULLusage_sinceoutside theDATABASErows, the server uptime still in the header row, and@dedupe_only = 1giving no per-database note. Every scratch database, history row, backup file and the scratch proc are dropped afterwards (checked on SQL2022).Group A's existing branch now keys on Crap's own usage window, not only server uptime: a long-up server with a newly created
Crapskips Rule 1 for it, so the guard assertions apply. On the lab nothing changes.tests/README.mdcounts are now 58/30/134/4 (131 forrule_coverage_test.pyon a server up more than 7 days) and it has a short paragraph on the new group.Not run: CI containers (Linux). The harness uses the default backup directory with either path separator and wraps the backup file delete in TRY/CATCH.
Follow-up 2b39213:
SERVERPROPERTY('InstanceDefaultBackupPath')is NULL before SQL Server 2019, so the backup path was NULL on SQL Server 2017. The harness now falls back toInstanceDefaultDataPathand drops its trailing separator.rule_coverage_test.pyon SQL2017: 133 passed, noic_win_*databases left.For the README at release
Lines ~93-94 of
sp_IndexCleanup/README.mddescribe the uptime guard. Suggested replacement text:Per-day rates use the database window (9a2ec08)
daily_write_ops_saved,daily_lock_waits_savedanddaily_latch_waits_savedon TABLE rows now divide by the table's database usage window (#databases.usage_window_days), not server uptime. Usage and operational counters all restart when a database is restored or created, so all three use the same window. The TABLE branch LEFT JOINs#databasesby name (its primary key), with@server_uptime_daysas the fallback and the existingNULLIF(..., 0)kept. When the window is the server start it is the same DATEDIFF as uptime, so the result is unchanged. SUMMARY and DATABASE rows still show N/A, and the header text still uses server uptime.Test: Group A-Window now gives the fixture 120 updates touching both indexes and asserts that
ic_win_mid(created 10 days ago, server start pinned 12) shows the 10-day answer (12, where a 12-day divisor gives 10).Results at 9a2ec08: SQL2022
run_tests.py58,fixture_cases_test.py30,rule_coverage_test.py134,no_access_test.py4, all passed. SQL2017rule_coverage_test.py134 passed. Noic_win_*databases left on either server.tests/README.mdcounts updated (134, and 131 on a server up more than 7 days). Onlyrule_coverage_test.pygained an assertion, so the other three counts are unchanged.Considered, not added
restore_type: any restore into the name (full, differential, log) counts, since each one brings the database online and resets its usage stats.msdband the user database is not handled (names differing only by case on a case-sensitivemsdb). No concrete trigger.🤖 Generated with Claude Code
https://claude.ai/code/session_01UT4xePKcEVUmyzwGYSEuqU