diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..b38ec3c4 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,82 @@ +name: Claude Code Review + +on: + pull_request: + # Only auto-review PRs into dev. Promotions from dev to main re-package + # already-reviewed work, and comment @claude on any PR for an on-demand review. + branches: [dev] + types: [opened, synchronize, ready_for_review, reopened] + # To limit reviews to certain files, uncomment and adjust: + # paths: + # - "**/*.sql" + # - "**/*.ps1" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + claude-review: + # Skip draft PRs (reviewed when marked ready) and fork PRs: GitHub does not + # expose repo secrets to workflows triggered from forks, so the review can + # only run on same-repo branches. Use @claude comments for fork PRs. + if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + track_progress: true + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + This repository contains Erik Darling's SQL Server troubleshooting stored + procedures. Most changes are to large, self-contained T-SQL stored + procedures, plus PowerShell tooling and CI scripts. The repository + CLAUDE.md is the canonical T-SQL style guide. + + Review this pull request with these priorities: + + 1. Correctness: logic errors, NULL handling, dynamic SQL construction + (quoting, QUOTENAME, parameterized sp_executesql), cursor handling, + error handling, and overflow safety (COUNT_BIG, ROWCOUNT_BIG). + 2. SQL Server version compatibility: procedures support a range of + versions (generally 2016 through 2025, plus Azure SQL DB and Managed + Instance where the procedure documents support). Flag newer T-SQL + syntax (STRING_AGG, CONCAT_WS, GREATEST/LEAST, TRIM, etc.) or DMV + columns used without the version/feature gating patterns the + procedure already uses elsewhere. + 3. Self-containment: procedures must not gain dependencies on helper + functions, views, or other database objects. + 4. Style: flag clear CLAUDE.md violations (tabs, -- comments instead of + /* */ blocks, abbreviated data types or keywords, missing table + aliases, missing semicolons). Do not nitpick. + + Repo-specific rules: + - Install-All/DarlingData.sql is auto-generated by CI. If this PR edits + it directly, flag that as a blocking problem: only per-procedure + source files should change. + - The @version / @version_date lines in procedures only change in + release PRs from the maintainer (erikdarlingdata). If any other PR + bumps them, ask for that to be reverted. + + The PR branch is already checked out in the current working directory. + + Use inline comments for specific issues and one summary comment for the + overall review. Only the GitHub comments you post will be seen, so put + all feedback there rather than in a normal response. If the changes look + good, say so briefly and do not invent problems. + + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..2d7dc2af --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,39 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # Only users with write access to the repo can trigger this; the action + # enforces that check on the commenting user. This is also the way to get a + # review on fork PRs, where the automatic review workflow cannot run. + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/.github/workflows/sql-tests.yml b/.github/workflows/sql-tests.yml index 420f3118..133bcfdd 100644 --- a/.github/workflows/sql-tests.yml +++ b/.github/workflows/sql-tests.yml @@ -41,7 +41,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install sqlcmd + - name: Install sqlcmd tools run: | # Ubuntu 24.04 runners have Microsoft repo pre-configured; avoid Signed-By conflicts if ! grep -rql 'packages.microsoft.com' /etc/apt/sources.list.d/ 2>/dev/null; then @@ -52,6 +52,29 @@ jobs: sudo apt-get update sudo ACCEPT_EULA=Y apt-get install -y mssql-tools18 + - name: Install go-sqlcmd (for the assertion harnesses) + env: + GH_TOKEN: ${{ github.token }} + run: | + # The Python assertion harnesses were written and validated against the + # go-based sqlcmd, not mssql-tools18, so install that to avoid + # output-parsing differences. It is not in the apt repo for these + # runners, so pull the latest linux-amd64 release binary. + set -euxo pipefail + assets=$(curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + https://api.github.com/repos/microsoft/go-sqlcmd/releases/latest \ + | grep -oE 'https://[^"]+' | grep -iE 'linux[-_]amd64.*\.tar\.(bz2|gz)$') + echo "$assets" + url=$(echo "$assets" | head -1) + test -n "$url" + mkdir -p /tmp/gosqlcmd + curl -fsSL -o /tmp/gosqlcmd.tar "$url" + tar -xf /tmp/gosqlcmd.tar -C /tmp/gosqlcmd + bin=$(find /tmp/gosqlcmd -type f -name sqlcmd | head -1) + test -n "$bin" + sudo install "$bin" /usr/local/bin/sqlcmd + /usr/local/bin/sqlcmd --version + - name: Create test database env: SA_PASSWORD: CI_Test#2026! @@ -105,3 +128,102 @@ jobs: SA_PASSWORD: CI_Test#2026! run: | /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -d DarlingData_CI_Test -i ".github/sql/test_basic_execution.sql" + + # The harnesses connect to master and run EXECUTE dbo.sp_... there (as they + # do on a local box, where these procs live in master), so install the + # bundle into master too. The smoke tests above use DarlingData_CI_Test. + - name: Install procedures into master (for the assertion harnesses) + env: + SA_PASSWORD: CI_Test#2026! + run: | + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -d master -i "Install-All/DarlingData.sql" + + # Assertion harnesses. These EXECUTE the output the procedures generate + # (findings, session DDL) rather than just running them, which is what + # catches the "reads correctly but is wrong when run" bugs the smoke tests + # above cannot. They are self-contained (build and drop their own scratch + # databases, sessions, and config), except sp_IndexCleanup, which needs a + # StackOverflow2013.dbo.Users table that the fixture step below creates. + # They use the go-based sqlcmd via the SQLCMD_* environment overrides. + # Encryption is disabled (-N disable): the modern Go TLS stack rejects the + # SQL Server 2017 container's self-signed certificate outright, and -C + # (trust cert) does not bypass that; there is nothing to secure on a + # localhost throwaway container. + - name: sp_PerfCheck assertion harness + env: + SA_PASSWORD: CI_Test#2026! + SQLCMD_BIN: /usr/local/bin/sqlcmd + SQLCMD_CONN_ARGS: "-C -N disable" + run: | + python3 sp_PerfCheck/tests/run_tests.py --server localhost --password "$SA_PASSWORD" + + - name: sp_HumanEvents assertion harness + env: + SA_PASSWORD: CI_Test#2026! + SQLCMD_BIN: /usr/local/bin/sqlcmd + SQLCMD_CONN_ARGS: "-C -N disable" + run: | + python3 sp_HumanEvents/tests/run_tests.py --server localhost --password "$SA_PASSWORD" + + # sp_IndexCleanup's harness needs a StackOverflow2013 with a Users table. + # The containers have no such database, so build a faithful-schema + # synthetic stand-in. Real values are irrelevant: the harness forces its + # index reads and every rule is structural (dedupe / overlap / usage + # floor). See sp_IndexCleanup/tests/fixture_stackoverflow2013.sql. + - name: Create StackOverflow2013 fixture (for the sp_IndexCleanup harness) + env: + SA_PASSWORD: CI_Test#2026! + run: | + /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" -C -No -b -i "sp_IndexCleanup/tests/fixture_stackoverflow2013.sql" + + # Four runners: adversarial synthetic tables, the Users dedupe cases + # (which also EXECUTE every generated MERGE/DISABLE script), rule coverage + # in its own scratch databases, and the no-database-access preflight guard. + - name: sp_IndexCleanup assertion harness + env: + SA_PASSWORD: CI_Test#2026! + SQLCMD_BIN: /usr/local/bin/sqlcmd + SQLCMD_CONN_ARGS: "-C -N disable" + run: | + python3 sp_IndexCleanup/tests/run_tests.py --server localhost --password "$SA_PASSWORD" + python3 sp_IndexCleanup/tests/fixture_cases_test.py --server localhost --password "$SA_PASSWORD" + python3 sp_IndexCleanup/tests/rule_coverage_test.py --server localhost --password "$SA_PASSWORD" + python3 sp_IndexCleanup/tests/no_access_test.py --server localhost --password "$SA_PASSWORD" + + # ReproBuilder is fully portable: every plan is an embedded string constant + # and its one table-dependent case builds a fixture in tempdb. No user + # database needed. + - name: sp_QueryReproBuilder assertion harness + env: + SA_PASSWORD: CI_Test#2026! + SQLCMD_BIN: /usr/local/bin/sqlcmd + SQLCMD_CONN_ARGS: "-C -N disable" + run: | + python3 sp_QueryReproBuilder/tests/run_tests.py --server localhost --password "$SA_PASSWORD" + + # sp_LogHunter builds an xp_readerrorlog command string per search term + # and executes each one, swallowing any that throw into #errors rather + # than raising them -- so a broken generated command looks like a clean, + # empty result set. The harness asserts #errors stays empty across the + # matrix, and writes a marker to the error log via RAISERROR ... WITH LOG + # as a positive control so the absence assertions cannot pass vacuously. + - name: sp_LogHunter assertion harness + env: + SA_PASSWORD: CI_Test#2026! + SQLCMD_BIN: /usr/local/bin/sqlcmd + SQLCMD_CONN_ARGS: "-C -N disable" + run: | + python3 sp_LogHunter/tests/run_tests.py --server localhost --password "$SA_PASSWORD" + + # sp_QuickieStore is ~16k lines of dynamic SQL whose shape changes with + # almost every parameter, so this is a parameter matrix: all 39 sort + # orders, the wait filters, execution types, query types and the + # expert/format grid, each of which executes the statement it just built. + # It creates and drops its own Query Store scratch database. + - name: sp_QuickieStore assertion harness + env: + SA_PASSWORD: CI_Test#2026! + SQLCMD_BIN: /usr/local/bin/sqlcmd + SQLCMD_CONN_ARGS: "-C -N disable" + run: | + python3 sp_QuickieStore/tests/run_tests.py --server localhost --password "$SA_PASSWORD" diff --git a/.gitignore b/.gitignore index 7958050e..a37cf3a8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ *.lock .DS_Store .claude/ +__pycache__/ +*.pyc diff --git a/Export-SqlResults/Export-SqlResults.ps1 b/Export-SqlResults/Export-SqlResults.ps1 new file mode 100644 index 00000000..9d041c55 --- /dev/null +++ b/Export-SqlResults/Export-SqlResults.ps1 @@ -0,0 +1,406 @@ +<# +.SYNOPSIS + Runs a query or procedure and exports every result set to CSV, spilling + artifact XML (execution plans, deadlock graphs, blocked process reports) + and oversized text (query text, repro scripts, DDL) to individual files + instead of trying to cram them into a cell. + +.DESCRIPTION + Built for the DarlingData toolkit - sp_QuickieStore, sp_QuickieCache, + sp_PressureDetector, sp_HealthParser, sp_HumanEvents, sp_HumanEventsBlockViewer, + sp_QueryReproBuilder, sp_IndexCleanup - plus sp_BlitzLock and anything else + that returns multiple result sets with XML scattered through them. + + Uses System.Data.SqlClient directly - no PowerShell module required, so it + runs on a locked-down production server without installing anything. Works + in Windows PowerShell 5.1 and PowerShell 7. + + Values are classified by their first XML node, not by substring sniffing: + + root ShowPlanXML -> .sqlplan (always spills) + root deadlock / deadlock-list -> .xdl (always spills) + root blocked-process-report, + blocked-process, blocking-process -> .xml (always spills) + root event (raw XE wrapper) -> .xml (always spills) + other XML -> .xml (spills over -InlineMaxChars) + plain text -> .sql/.txt (spills over -InlineMaxChars) + + The DarlingData procs wrap query text, repro scripts, and plans that fail + casting to xml in processing instructions (, , + , ) to make them clickable in SSMS. Those wrappers + are stripped on export: wrapped plans land as openable .sqlplan files, and + query text / scripts land as runnable .sql files - inline cells get the + unwrapped text too. + + Spilled files go under files\ and the CSV cell holds the relative path. + Spills are deduplicated by content hash - procs like sp_HealthParser repeat + the same deadlock graph on every process/frame row, so identical values + share one file. manifest.csv correlates result set, row, column, and file + (many cells can map to one file). Server messages + (RAISERROR WITH NOWAIT progress, PRINT output) stream to the console and to + messages.log. GO batch separators are honored in -Query and -InputFile. + +.EXAMPLE + .\Export-SqlResults.ps1 -ServerInstance . -Database master -Query "EXEC dbo.sp_QuickieStore @get_all_databases = 1;" -OutDir C:\temp\quickiestore + +.EXAMPLE + .\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -InputFile .\healthparser.sql -OutDir C:\temp\prod01-healthparser -CommandTimeout 600 + +.EXAMPLE + .\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -Query "EXEC dbo.sp_HumanEventsBlockViewer @session_name = N'blocked_process_report';" -OutDir C:\temp\prod01-blocking -Credential (Get-Credential) + +.NOTES + Copyright 2026 Darling Data, LLC + https://www.erikdarling.com/ + + Released under MIT license + + For support, head over to GitHub: + https://code.erikdarling.com +#> +[CmdletBinding(DefaultParameterSetName = 'Query')] +param( + [Parameter(Mandatory)] + [string]$ServerInstance, + + [string]$Database = 'master', + + [Parameter(Mandatory, ParameterSetName = 'Query')] + [string]$Query, + + [Parameter(Mandatory, ParameterSetName = 'File')] + [string]$InputFile, + + [Parameter(Mandatory)] + [string]$OutDir, + + # Values longer than this spill to their own file - XML and plain text alike. + # Keeps short values inline for triage; artifact XML (plans, deadlock graphs, + # blocked process reports) always spills regardless of size. + [int]$InlineMaxChars = 8000, + + [int]$CommandTimeout = 600, + + # SQL auth. Omit entirely for Windows auth. Passed as a SqlCredential so the + # password never lands in the connection string. + [pscredential]$Credential, + + # Collapse newlines inside CSV cells to single spaces. Off by default: + # quoted CSV fields carry embedded newlines fine (Excel included), and + # collapsing destroys query text formatting. + [switch]$FlattenNewlines, + + # Prefix cells starting with = + - @ with a single quote so Excel doesn't + # evaluate them as formulas (query text starting with -- is the usual + # victim). Off by default because it alters the raw data. + [switch]$ExcelSafe +) + +$ErrorActionPreference = 'Stop' + +if ($PSCmdlet.ParameterSetName -eq 'File') { + if (-not (Test-Path $InputFile)) { throw "InputFile not found: $InputFile" } + $Query = Get-Content $InputFile -Raw +} + +# Resolve OutDir against the PowerShell location, not the process CWD, so +# relative paths work with the raw .NET file APIs used below. +$OutDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutDir) +New-Item -ItemType Directory -Path $OutDir -Force | Out-Null +$filesDir = Join-Path $OutDir 'files' +$messagesPath = Join-Path $OutDir 'messages.log' + +# Split on GO batch separators (line-start only, sqlcmd-style; GO repeats). +function Split-SqlBatches { + param([string]$Sql) + + $batches = New-Object System.Collections.Generic.List[string] + $sb = New-Object System.Text.StringBuilder + + foreach ($line in ($Sql -split "`r?`n")) { + if ($line -match '^\s*GO(?:\s+(\d+))?\s*$') { + $repeat = 1 + if ($Matches[1]) { $repeat = [int]$Matches[1] } + $text = $sb.ToString() + if ($text.Trim().Length -gt 0) { + for ($r = 0; $r -lt $repeat; $r++) { $batches.Add($text) } + } + [void]$sb.Clear() + } + else { + [void]$sb.AppendLine($line) + } + } + + $text = $sb.ToString() + if ($text.Trim().Length -gt 0) { $batches.Add($text) } + + return ,$batches +} + +# Render a reader value as text without losing anything to culture or +# PowerShell's default casts: datetime2 keeps its fractional seconds, and +# varbinary handles/hashes come out as 0x hex instead of a byte list. +function Format-SqlValue { + param($Value) + + if ($Value -is [datetime]) { + return $Value.ToString('yyyy-MM-dd HH:mm:ss.fffffff', [System.Globalization.CultureInfo]::InvariantCulture) + } + if ($Value -is [System.DateTimeOffset]) { + return $Value.ToString('o', [System.Globalization.CultureInfo]::InvariantCulture) + } + if ($Value -is [byte[]]) { + if ($Value.Length -eq 0) { return '0x' } + return '0x' + [System.BitConverter]::ToString($Value).Replace('-', '') + } + if ($Value -is [timespan]) { + return $Value.ToString('c') + } + return [string]$Value +} + +# Classify a value by its first XML node. Returns Text (unwrapped if it was a +# processing instruction), Ext, and whether it always spills. Artifacts meant +# to be opened in a tool always spill - a 4KB deadlock graph is useless inline, +# because its value is being openable as a graph. +function Resolve-Field { + param( + [string]$Value, + [bool]$LooksXml, + [string]$ColumnName + ) + + if ($LooksXml) { + $head = $Value.Substring(0, [Math]::Min(600, $Value.Length)) + $m = [regex]::Match( + $head, + '(?s)^\s*(?:<\?xml\b.*?\?>\s*)?(?:\s*)*<(?\?)?(?[A-Za-z_][\w.\-]*)' + ) + if ($m.Success) { + $nodeName = $m.Groups['name'].Value + + if ($m.Groups['pi'].Success) { + if ($nodeName -eq 'xml') { + # A bare xml declaration with nothing recognizable after it. + return @{ Text = $Value; Ext = '.xml'; Always = $false } + } + + # DarlingData processing-instruction wrapper: strip it and + # classify the payload. Plans that failed casting to xml become + # openable .sqlplan files; query text and scripts become .sql. + $inner = $Value -replace '(?s)^\s*<\?[A-Za-z_][\w.\-]*\s?', '' + $inner = $inner -replace '(?s)\s*\?>\s*$', '' + + if ($inner.TrimStart().StartsWith(' + +# Export-SqlResults + +A PowerShell script that runs a query or stored procedure and exports every result set to CSV, spilling execution plans, deadlock graphs, blocked process reports, and oversized query text or scripts to individual files that open in the right tool. + +SSMS grid results are a dead end for this stuff: plans get truncated, XML columns are unreadable, and nothing survives being pasted into a ticket. This script gets proc output onto disk in a shape you can actually send to someone. + +Built for the scripts in this repo — sp_QuickieStore, sp_QuickieCache, sp_PressureDetector, sp_HealthParser, sp_HumanEvents, sp_HumanEventsBlockViewer, sp_QueryReproBuilder, sp_IndexCleanup, sp_PerfCheck, sp_LogHunter — plus sp_BlitzLock and anything else that returns result sets with XML scattered through them. + +## Requirements + +- Windows PowerShell 5.1 or PowerShell 7 — no modules to install; it uses System.Data.SqlClient directly, so it runs on locked-down servers +- Windows auth by default, or pass `-Credential` for SQL auth (sent as a SqlCredential, never embedded in the connection string) + +## Usage + +```powershell +.\Export-SqlResults.ps1 -ServerInstance . -Database master -Query "EXEC dbo.sp_QuickieStore @get_all_databases = 1;" -OutDir C:\temp\quickiestore +``` + +```powershell +.\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -InputFile .\healthparser.sql -OutDir C:\temp\prod01-healthparser -CommandTimeout 600 +``` + +```powershell +.\Export-SqlResults.ps1 -ServerInstance PROD01 -Database master -Query "EXEC dbo.sp_HumanEventsBlockViewer @session_name = N'blocked_process_report';" -OutDir C:\temp\prod01-blocking -Credential (Get-Credential) +``` + +## Parameters + +| Parameter Name | Data Type | Default Value | Description | +|----------------|-----------|---------------|-------------| +| @ServerInstance | string | required | Server or instance to connect to | +| @Database | string | master | Database context for the query | +| @Query | string | | T-SQL to run (this or -InputFile) | +| @InputFile | string | | Path to a .sql file to run (GO batches are honored) | +| @OutDir | string | required | Output folder; created if missing | +| @InlineMaxChars | int | 8000 | Values longer than this spill to their own file; artifact XML (plans, deadlock graphs, blocked process reports) always spills | +| @CommandTimeout | int | 600 | Command timeout in seconds | +| @Credential | pscredential | | SQL auth; omit for Windows auth | +| @FlattenNewlines | switch | off | Collapse newlines in CSV cells to spaces (quoted CSV carries newlines fine without it) | +| @ExcelSafe | switch | off | Prefix cells starting with = + - @ so Excel does not evaluate them as formulas | + +## How values are classified + +Every value is classified by its first XML node, not by substring sniffing: + +| Content | Extension | Spills | +|---------|-----------|--------| +| root ShowPlanXML | .sqlplan | always | +| root deadlock / deadlock-list | .xdl | always | +| root blocked-process-report / blocked-process / blocking-process | .xml | always | +| root event (raw Extended Events wrapper) | .xml | always | +| other XML | .xml | over -InlineMaxChars | +| plain text | .sql or .txt | over -InlineMaxChars | + +The scripts in this repo wrap query text, repro scripts, and plans that fail casting to xml in processing instructions (``, ``, ``, ``) to make them clickable in SSMS. Those wrappers are stripped on export: wrapped plans land as openable .sqlplan files, and query text and repro scripts land as runnable .sql files. + +## Output layout + +``` +OutDir\ + resultset01.csv one CSV per result set, in output order + resultset02.csv + files\ spilled artifacts; CSV cells hold the relative path + rs02_r0001_c05_query_plan.sqlplan + rs02_r0001_c06_deadlock_graph.xdl + manifest.csv result set / row / column -> file correlation + messages.log RAISERROR WITH NOWAIT progress and PRINT output +``` + +Spilled files are deduplicated by content hash. Procs like sp_HealthParser repeat the same deadlock graph on every process and frame row — identical values share one file on disk, and the manifest maps every referencing cell to it. + +## Warning + +Exports are only as small as what you ask for. sp_HealthParser against a server with a deep system_health backlog can produce tens of thousands of distinct deadlock graphs and hundreds of megabytes of files. Aim it somewhere with disk space. + +Copyright 2026 Darling Data, LLC +Released under MIT license diff --git a/ProtectSession/README.md b/ProtectSession/README.md index 0b47ae2d..2a3a478d 100644 --- a/ProtectSession/README.md +++ b/ProtectSession/README.md @@ -12,10 +12,10 @@ It is **not** a general-purpose blocking killer; it watches exactly one session | parameter_name | data_type | description | valid_inputs | defaults | |---------------------------------|---------------|------------------------------------------------------------------------------------------|-----------------------------|----------------------| -| @protected_session_id | integer | the session_id to keep alive | a session_id of another spid | NULL (required) | +| @protected_session_id | integer | the session_id you want kept alive (watched, not run from) | a session_id of another spid | NULL (required) | | @block_threshold_seconds | integer | how long the protected session must wait on an LCK before any action | a positive integer | 5 | | @check_interval_seconds | integer | how often each cycle samples and acts | a positive integer | 1 | -| @abort_on_modification_block | bit | when blocked by a permanent-data modification, kill the protected session instead | 0 or 1 | 0 | +| @abort_on_modification_block | bit | when blocked by a permanent-data modification, kill the protected session instead of the blocker | 0 or 1 | 0 | | @kill_modification_blockers | bit | kill permanent-data modification blockers too (rolls back their work) | 0 or 1 | 0 | | @abort_reason | nvarchar(2048)| free-text reason printed when the protected session is aborted | any string or NULL | NULL | | @debug | bit | print each cycle's `#blockers` contents | 0 or 1 | 0 | diff --git a/README.md b/README.md index 58d3a1b6..97be3885 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,11 @@ Current valid parameter details: | @minimum_disk_latency_ms | smallint | low bound for reporting disk latency | a reasonable number of milliseconds for disk latency | 100 | | @cpu_utilization_threshold | smallint | low bound for reporting high cpu utlization | a reasonable cpu utlization percentage | 50 | | @skip_waits | bit | skips waits when you do not need them on every run | 0 or 1 | 0 | -| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | a valid tinyint: 0-255 | 0 | -| @sample_seconds | tinyint | take a sample of your server's metrics | 0 or 1 | 0 | -| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 | -| @log_database_name | sysname | database to store logging tables | valid database name | NULL | -| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL | +| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | 0 or 1 | 0 | +| @sample_seconds | tinyint | take a sample of your server's metrics | a valid tinyint: 0-255 | 0 | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) | +| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) | | @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'PressureDetector' | | @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 | | @troubleshoot_blocking | bit | show blocking chains instead of pressure analysis | 0 or 1 | 0 | @@ -125,9 +125,10 @@ Current valid parameter details: | parameter | name | description | valid_inputs | defaults | |------------------------|----------|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|-------------------------------------------------| | @event_type | sysname | used to pick which session you want to run | "blocking", "query", "waits", "recompiles", "compiles" and certain variations on those words | "query" | -| @query_duration_ms | integer | (>=) used to set a minimum query duration to collect data for | an integer | 500 (ms) | -| @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", "spills", and you can add "avg" to sort by averages, e.g. "avg cpu" | "cpu" | +| @query_duration_ms | decimal | (>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration | a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations | 500 (ms) | +| @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time" | "cpu" | | @skip_plans | bit | when you use the "query" event, lets you skip collecting actual execution plans |1 or 0 | 0 | +| @keep_prepare_rpc | bit | when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare) that are normally filtered out; shows whether a driver is preparing and reusing statements, e.g. pyodbc's fast_executemany | 1 or 0 | 0 | | @blocking_duration_ms | integer | (>=) used to set a minimum blocking duration to collect data for | an integer | 500 (ms) | | @wait_type | nvarchar | (inclusive) filter to only specific wait types | a single wait type, or a CSV list of wait types | "all", which uses a list of "interesting" waits | | @wait_duration_ms | integer | (>=) used to set a minimum time per wait to collect data for | an integer | 10 (ms) | @@ -141,10 +142,10 @@ Current valid parameter details: | @object_schema | sysname | (inclusive) the schema of the object you want to filter to; only needed with blocking events | a stringy thing | dbo | | @requested_memory_mb | integer | (>=) the memory grant a query must ask for to have data collected | an integer | 0 | | @seconds_sample | tinyint | the duration in seconds to run the event session for | an integer | 10 | -| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits | 1 or 0 | 0 | +| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits. only use if you're okay with potentially adding a lot of observer overhead on your system, or for testing purposes. | 1 or 0 | 0 | | @target_output | sysname | output target for extended events | "ring_buffer" or "event_file" (event_file not available for Azure SQL DB or Managed Instance) | "ring_buffer" | | @keep_alive | bit | creates a permanent session, either to watch live or log to a table from | 1 or 0 | 0 | -| @custom_name | nvarchar | if you want to custom name a permanent session | a stringy thing | intentionally left blank | +| @custom_name | sysname | if you want to custom name a permanent session | a stringy thing | intentionally left blank | | @output_database_name | sysname | the database you want to log data to | a valid database name | intentionally left blank | | @output_schema_name | sysname | the schema you want to log data to | a valid schema | dbo | | @delete_retention_days | integer | how many days of logged data you want to keep | a POSITIVE integer | 3 (days) | @@ -218,8 +219,8 @@ Current valid parameter details: | parameter_name | data_type | description | valid_inputs | defaults | |-----------------------|-----------|-------------------------------------------------|------------------------------------------------------------------------|------------------------------------| -| @session_name | sysname | name of the extended event session to pull from | extended event session name capturing sqlserver.blocked_process_report | keeper_HumanEvents_blocking | -| @target_type | sysname | target of the extended event session | event_file or ring_buffer | NULL | +| @session_name | sysname | name of the extended event session to pull from | extended event session name capturing sqlserver.blocked_process_report, system_health also works | keeper_HumanEvents_blocking | +| @target_type | sysname | target type of the extended event session (ring buffer, file) or 'table' to read from a table | event_file or ring_buffer or table | NULL | | @start_date | datetime2 | filter by date | a reasonable date | NULL; will shortcut to last 7 days | | @end_date | datetime2 | filter by date | a reasonable date | NULL | | @database_name | sysname | filter by database name | a database that exists on this server | NULL | @@ -227,14 +228,15 @@ Current valid parameter details: | @target_database | sysname | database containing the table with BPR data | a valid database name | NULL | | @target_schema | sysname | schema of the table | a valid schema name | NULL | | @target_table | sysname | table name | a valid table name | NULL | -| @target_column | sysname | column containing XML data | a valid column name | NULL | -| @timestamp_column | sysname | column containing timestamp (optional) | a valid column name | NULL | -| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 | -| @log_database_name | sysname | database to store logging tables | a valid database name | NULL | -| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL | +| @target_column | sysname | column containing XML data | an XML column containing blocked process report data | NULL | +| @timestamp_column | sysname | column containing UTC timestamp for filtering (optional). MUST be stored in UTC — @start_date and @end_date are shifted to UTC internally to match the XML @timestamp attribute, and the same UTC-shifted values are used for this column filter. A column in local time will be filtered against the wrong window. | a datetime / datetime2 / datetimeoffset column storing UTC timestamps | NULL | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | a valid database name | NULL (current database) | +| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL (dbo) | | @log_table_name_prefix| sysname | prefix for all logging tables | a valid table name prefix | 'HumanEventsBlockViewer' | | @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | a valid integer | 30 | -| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 | 5000 | +| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 (0 = unlimited) | 5000 | +| @skip_execution_plans | bit | skip gathering and returning the execution plans result set and jump straight to the findings rollup | 0 or 1 | 0 | | @help | bit | how you got here | 0 or 1 | 0 | | @debug | bit | dumps raw temp table contents | 0 or 1 | 0 | | @version | varchar | OUTPUT; for support | none; OUTPUT | none; OUTPUT | @@ -277,14 +279,14 @@ Current valid parameter details: | parameter_name | data_type | description | valid_inputs | defaults | |-----------------------------------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------| | @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database name if NULL | -| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg prefix also accepted, e.g. "avg cpu") | cpu | +| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg/average prefix also accepted, e.g. avg cpu, average duration) | cpu | | @top | bigint | the number of queries you want to pull back | a positive integer between 1 and 9,223,372,036,854,775,807 | 10 | | @start_date | datetimeoffset | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days | | @end_date | datetimeoffset | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL | | @timezone | sysname | user specified time zone to override dates displayed in results | SELECT tzi.* FROM sys.time_zone_info AS tzi; | NULL | | @execution_count | bigint | the minimum number of executions a query must have | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL | | @duration_ms | bigint | the minimum duration a query must have to show up in results | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL | -| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception) | regular, aborted, exception | NULL | +| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception, failed); failed bundles aborted and exception | regular, aborted, exception, failed | NULL | | @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL | | @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database, can use wildcards | NULL | | @include_plan_ids | nvarchar | a list of plan ids to search for | a string; comma separated for multiple ids | NULL | @@ -310,7 +312,7 @@ Current valid parameter details: | @query_type | varchar | filter for only ad hoc queries or only from queries from modules | ad hoc, adhoc, proc, procedure, whatever. | NULL | | @expert_mode | bit | returns additional columns and results | 0 or 1 | 0 | | @hide_help_table | bit | hides the "bottom table" that shows help and support information | 0 or 1 | 0 | -| @format_output | bit | returns numbers formatted with commas | 0 or 1 | 1 | +| @format_output | bit | returns numbers formatted with commas and most decimals rounded away | 0 or 1 | 1 | | @get_all_databases | bit | looks for query store enabled user databases and returns combined results from all of them | 0 or 1 | 0 | | @include_databases | nvarchar(MAX) | comma-separated list of databases to include (only when @get_all_databases = 1) | a string; comma separated database names | NULL | | @exclude_databases | nvarchar(MAX) | comma-separated list of databases to exclude (only when @get_all_databases = 1) | a string; comma separated database names | NULL | @@ -321,8 +323,15 @@ Current valid parameter details: | @regression_baseline_end_date | datetimeoffset | the end date of the baseline that you are checking for regressions against (if any), will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL; One week after @regression_baseline_start_date if that is specified | | @regression_comparator | varchar | what difference to use ('relative' or 'absolute') when comparing @sort_order's metric for the normal time period with any regression time period. | relative, absolute | NULL; absolute if @regression_baseline_start_date is specified | | @regression_direction | varchar | when comparing against any regression baseline, what do you want the results sorted by ('magnitude', 'improved', or 'regressed')? | regressed, worse, improved, better, magnitude, absolute, whatever | NULL; regressed if @regression_baseline_start_date is specified | -| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash | 0 or 1 | 0 | +| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash, may be skewed by query_hash and query_plan_hash bugs with forced plans/plan guides | 0 or 1 | 0 | +| @find_high_impact | bit | finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions | 0 or 1 | 0 | +| @primary_window | nvarchar | with @find_high_impact, restricts results to queries whose majority activity is in this window (business, off-hours, or weekend) | business, off-hours, or weekend (any unambiguous prefix works: b, biz, off, overnight, w, wknd, etc.) | NULL | | @include_maintenance | bit | add maintenance operations such as index creation to the result set | 0 or 1 | 0 | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | a valid database name | NULL; current database | +| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL; dbo | +| @log_table_name_prefix | sysname | prefix for all logging table names | a valid identifier | QuickieStore | +| @log_retention_days | integer | days of data to retain, 0 = keep indefinitely | a positive integer, or 0 | 30 | | @help | bit | how you got here | 0 or 1 | 0 | | @debug | bit | prints dynamic sql, statement length, parameter and variable values, and raw temp table contents | 0 or 1 | 0 | | @troubleshoot_performance | bit | set statistics xml on for queries against views | 0 or 1 | 0 | @@ -349,15 +358,20 @@ Current valid parameter details: | parameter_name | data_type | description | valid_inputs | defaults | |-----------------------------|--------------|------------------------------------------------------------------------------------------------------|------------------------------------------|------------| -| @top | integer | candidates per metric dimension before dedup | a positive integer | 10 | +| @top | bigint | candidates per metric dimension before dedup | a positive integer | 10 | +| @sort_order | varchar | secondary sort after impact_score | cpu, duration, reads, writes, memory, spills, executions | cpu | | @database_name | sysname | filter to a specific database | a valid database name | NULL | | @start_date | datetime | only include plans created after this date | a valid datetime | NULL | | @end_date | datetime | only include plans created before this date | a valid datetime | NULL | | @minimum_execution_count | bigint | minimum execution count to include a query | a positive integer | 2 | | @ignore_system_databases | bit | exclude system databases (master, model, msdb, tempdb) | 0 or 1 | 1 | | @impact_threshold | decimal(3,2) | minimum impact_score (0.00-1.00) to surface in results | 0.00 to 1.00 | 0.50 | +| @find_single_use_plans | bit | show single-use plans consuming the most memory | 0 or 1 | 0 | +| @find_duplicate_plans | bit | show query hashes with multiple cached plans | 0 or 1 | 0 | | @debug | bit | print diagnostic information | 0 or 1 | 0 | | @help | bit | display parameter help | 0 or 1 | 0 | +| @version | varchar | OUTPUT; for support | none; OUTPUT | none; OUTPUT | +| @version_date | datetime | OUTPUT; for support | none; OUTPUT | none; OUTPUT | ```sql -- Basic execution @@ -405,15 +419,22 @@ Current valid parameter details: |---------------------|------------|-----------------------------------------------------------------------|----------------------------------------------------|----------------------------------| | @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database if NULL | | @start_date | datetimeoffset(7) | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days | -| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | current date/time | +| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL | | @include_plan_ids | nvarchar(4000) | a list of plan ids to search for | a string; comma separated for multiple ids | NULL | | @include_query_ids | nvarchar(4000) | a list of query ids to search for | a string; comma separated for multiple ids | NULL | +| @include_query_hashes | nvarchar(4000) | a list of query hashes to search for | a string; comma separated for multiple hashes | NULL | +| @include_plan_hashes | nvarchar(4000) | a list of query plan hashes to search for | a string; comma separated for multiple hashes | NULL | +| @include_sql_handles | nvarchar(4000) | a list of sql handles to search for | a string; comma separated for multiple handles | NULL | | @ignore_plan_ids | nvarchar(4000) | a list of plan ids to ignore | a string; comma separated for multiple ids | NULL | | @ignore_query_ids | nvarchar(4000) | a list of query ids to ignore | a string; comma separated for multiple ids | NULL | -| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL | +| @ignore_query_hashes | nvarchar(4000) | a list of query hashes to ignore | a string; comma separated for multiple hashes | NULL | +| @ignore_plan_hashes | nvarchar(4000) | a list of query plan hashes to ignore | a string; comma separated for multiple hashes | NULL | +| @ignore_sql_handles | nvarchar(4000) | a list of sql handles to ignore | a string; comma separated for multiple handles | NULL | +| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL | | @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database | NULL | -| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards added | NULL | -| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards added | NULL | +| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards will be added if missing | NULL | +| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards will be added if missing | NULL | +| @query_plan_xml | xml | a single query plan XML to process directly, bypassing Query Store | a valid ShowPlanXML document; when supplied, all Query Store filters are ignored | NULL | | @help | bit | return available parameter details, etc. | 0 or 1 | 0 | | @debug | bit | prints dynamic sql, statement length, parameter and variable values | 0 or 1 | 0 | | @version | varchar(30) | OUTPUT; for support | none; OUTPUT | none; OUTPUT | @@ -461,9 +482,9 @@ Current valid parameter details: | @skip_waits | bit | skip the wait stats section | 0 or 1 | 0 | | @pending_task_threshold | integer | minimum number of pending tasks to display | a valid integer | 10 | | @use_ring_buffer | bit | use ring_buffer target instead of file target for faster collection | 0 or 1 | 0 | -| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 | -| @log_database_name | sysname | database to store logging tables | valid database name | NULL | -| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) | +| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) | | @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'HealthParser' | | @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 | | @debug | bit | prints dynamic sql, selects from temp tables | 0 or 1 | 0 | @@ -527,6 +548,7 @@ Current valid parameter details: | Parameter | Data Type | Default | Description | |-----------|-----------|---------|-------------| | @database_name | sysname | NULL | Specific database to check; NULL runs against all accessible user databases | +| @help | bit | 0 | Displays this help information | | @debug | bit | 0 | Print diagnostic messages and intermediate query results | | @version | varchar(30) | NULL OUTPUT | Returns version number | | @version_date | datetime | NULL OUTPUT | Returns version date | @@ -558,9 +580,11 @@ Current valid parameter details: | @min_writes | bigint | 0 | Minimum number of writes for an index to be considered used | | @min_size_gb | decimal(10,2) | 0 | Minimum size in GB for an index to be analyzed | | @min_rows | bigint | 0 | Minimum number of rows for a table to be analyzed | +| @dedupe_only | bit | 0 | When set to 1, only performs index deduplication but does not mark unused indexes for removal | | @get_all_databases | bit | 0 | When set to 1, analyzes all eligible databases on the server | | @include_databases | nvarchar(max) | NULL | Comma-separated list of databases to include (used with @get_all_databases = 1) | | @exclude_databases | nvarchar(max) | NULL | Comma-separated list of databases to exclude (used with @get_all_databases = 1) | +| @sort_order | varchar(20) | default | Controls final result ordering: default groups by script type within each database, object groups all rows for the same index together (Key Subset disables sort under their replacement index) | | @help | bit | 0 | Displays help information | | @debug | bit | 0 | Prints debug information during execution | | @version | varchar(20) | NULL | OUTPUT parameter that returns the version number of the procedure | @@ -585,8 +609,8 @@ Current valid parameter details: | parameter_name | data_type | description | valid_inputs | defaults | |---|---|---|---|---| | @database_name | sysname | the database to clean query store in | a database name with query store enabled | NULL; current database if NULL | -| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none | all | -| @custom_query_filter | nvarchar(1024) | custom LIKE pattern for query text filtering | a valid LIKE pattern | NULL | +| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none, or comma-separated combination | all | +| @custom_query_filter | nvarchar(1024) | custom LIKE pattern for query text filtering; also applied when @cleanup_targets = all | a valid LIKE pattern | NULL | | @dedupe_by | varchar(50) | deduplication strategy | all, query_hash, plan_hash, none | all | | @min_age_days | integer | only remove queries not executed in this many days | a positive integer | NULL; no age filter | | @report_only | bit | report what would be removed without removing | 0 or 1 | 0 | diff --git a/TestBackupPerformance/README.md b/TestBackupPerformance/README.md index 793fd82a..dd96fc6f 100644 --- a/TestBackupPerformance/README.md +++ b/TestBackupPerformance/README.md @@ -2,7 +2,7 @@ # TestBackupPerformance -Finding the fastest backup settings for your database shouldn't require guesswork. This procedure tests every combination of file count (striping), compression, buffer count, and max transfer size, then ranks the results so you can see what actually works best on your hardware. +Finding the fastest backup settings for your database shouldn't require guesswork. This procedure tests every combination of file count (striping), compression, encryption, buffer count, and max transfer size, then ranks the results so you can see what actually works best on your hardware. Results are persisted to `dbo.backup_performance_results` so you can compare across runs, servers, and databases. @@ -14,19 +14,20 @@ Results are persisted to `dbo.backup_performance_results` so you can compare acr | @backup_path | nvarchar | directory path, DEFAULT for instance default, or NUL to discard | a valid directory path, DEFAULT, or NUL | NULL (required) | | @file_count_list | varchar | comma-separated list of file counts (backup stripes) | comma-separated integers | 1,2,4 | | @compression_list | varchar | comma-separated list: 0 = no compression, 1 = compressed | comma-separated 0s and 1s | 0,1 | +| @encryption_list | varchar | comma-separated list: 0 = no encryption, 1 = encrypted (requires a server certificate in master) | comma-separated 0s and 1s | 0 | | @buffer_count_list | varchar | comma-separated list of buffer counts (0 = SQL Server default) | comma-separated integers (0 for default) | 0,15,30,50 | -| @max_transfer_size_list | varchar | comma-separated list of max transfer sizes in bytes (0 = default 1MB) | comma-separated integers, multiples of 65536, max 4194304 | 0,2097152,4194304 | +| @max_transfer_size_list | varchar | comma-separated list of max transfer sizes in bytes (0 = default 1MB, max 4MB) | comma-separated integers, multiples of 65536, max 4194304 | 0,2097152,4194304 | | @stats | tinyint | backup completion percent to print progress at | 1-100 | 1 | | @iterations | integer | times to repeat each configuration for averaging | a positive integer | 1 | | @help | bit | how you got here | 0 or 1 | 0 | -| @debug | bit | prints dynamic sql, displays parameter and variable values | 0 or 1 | 0 | +| @debug | bit | prints dynamic sql, displays parameter and variable values, and table contents | 0 or 1 | 0 | | @version | varchar | OUTPUT; for support | none | none; OUTPUT | | @version_date | datetime | OUTPUT; for support | none | none; OUTPUT | ## Examples ```sql --- Test with defaults (24 combinations: 3 file counts x 2 compression x 4 buffer counts x 3 transfer sizes) +-- Test with defaults (72 combinations: 3 file counts x 2 compression x 1 encryption x 4 buffer counts x 3 transfer sizes) EXECUTE dbo.TestBackupPerformance @database_name = N'YourDatabase', @backup_path = N'D:\Backups'; @@ -61,7 +62,7 @@ EXECUTE dbo.TestBackupPerformance ## Result Sets 1. **All configurations ranked by throughput** -- every combination ranked best to worst -2. **Best config per compression setting** -- fastest compressed vs fastest uncompressed +2. **Best config per compression + encryption pairing** -- the fastest config in each compression/encryption category 3. **Parameter impact** -- which knob matters most (larger spread = bigger effect) 4. **Efficiency** -- best throughput per MB of buffer RAM (filtered to configs within 80% of peak) 5. **Consistency** -- min/max/stddev per config (only when `@iterations > 1`) diff --git a/sp_HealthParser/README.md b/sp_HealthParser/README.md index 80272d8d..6abfaec1 100644 --- a/sp_HealthParser/README.md +++ b/sp_HealthParser/README.md @@ -39,9 +39,9 @@ Typical result set will show you: | @skip_waits | bit | skip the wait stats section | 0 or 1 | 0 | | @pending_task_threshold | integer | minimum number of pending tasks to display | a valid integer | 10 | | @use_ring_buffer | bit | use ring_buffer target instead of file target for faster collection | 0 or 1 | 0 | -| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 | -| @log_database_name | sysname | database to store logging tables | valid database name | NULL | -| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) | +| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) | | @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'HealthParser' | | @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 | | @debug | bit | prints dynamic sql, selects from temp tables | 0 or 1 | 0 | diff --git a/sp_HealthParser/sp_HealthParser.sql b/sp_HealthParser/sp_HealthParser.sql index c1a0c740..503323cc 100644 --- a/sp_HealthParser/sp_HealthParser.sql +++ b/sp_HealthParser/sp_HealthParser.sql @@ -28,6 +28,14 @@ GO Copyright 2026 Darling Data, LLC https://www.erikdarling.com/ +For usage and licensing details, run: +EXECUTE sp_HealthParser + @help = 1; + +For working through errors: +EXECUTE sp_HealthParser + @debug = 1; + For support, head over to GitHub: https://code.erikdarling.com */ @@ -72,8 +80,8 @@ BEGIN SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - @version = '3.7', - @version_date = '20260701'; + @version = '3.8', + @version_date = '20260801'; IF @help = 1 BEGIN @@ -82,7 +90,8 @@ BEGIN 'hi, i''m sp_HealthParser!' UNION ALL SELECT 'you can use me to examine the contents of the system_health extended event session' UNION ALL SELECT 'i apologize if i take a long time, i have to do a lot of XML processing' UNION ALL - SELECT 'from your loving sql server consultant, erik darling: erikdarling.com'; + SELECT 'you got me from https://code.erikdarling.com' UNION ALL + SELECT 'from your loving sql server consultant, erik darling: https://erikdarling.com'; /* Parameters @@ -133,7 +142,7 @@ BEGIN WHEN N'@log_to_table' THEN N'0 or 1' WHEN N'@log_database_name' THEN N'any valid database name' WHEN N'@log_schema_name' THEN N'any valid schema name' - WHEN N'@log_table_name_prefix' THEN N'any valid identifier' + WHEN N'@log_table_name_prefix' THEN N'any valid identifier, 111 characters or fewer' WHEN N'@log_retention_days' THEN N'a positive integer' WHEN N'@version' THEN N'none' WHEN N'@version_date' THEN N'none' @@ -253,21 +262,29 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. @temp_table sysname, @insert_list sysname, @collection_sql nvarchar(MAX), - /*Log to table stuff*/ - @log_table_significant_waits sysname, - @log_table_waits_by_count sysname, - @log_table_waits_by_duration sysname, - @log_table_io_issues sysname, - @log_table_cpu_tasks sysname, - @log_table_memory_conditions sysname, - @log_table_memory_broker sysname, - @log_table_memory_node_oom sysname, - @log_table_system_health sysname, - @log_table_scheduler_issues sysname, - @log_table_severe_errors sysname, - @log_table_pending_tasks sysname, - @log_table_blocking sysname, - @log_table_deadlocks sysname, + /* + Log to table stuff. + + These hold fully-qualified, QUOTENAME'd three-part names, not + single identifiers — each part can quote out to 258 characters + (128 doubled brackets plus delimiters), so three parts and two + dots need 776. sysname silently truncated long-but-legal names + and the resulting CREATE TABLE failed. + */ + @log_table_significant_waits nvarchar(776), + @log_table_waits_by_count nvarchar(776), + @log_table_waits_by_duration nvarchar(776), + @log_table_io_issues nvarchar(776), + @log_table_cpu_tasks nvarchar(776), + @log_table_memory_conditions nvarchar(776), + @log_table_memory_broker nvarchar(776), + @log_table_memory_node_oom nvarchar(776), + @log_table_system_health nvarchar(776), + @log_table_scheduler_issues nvarchar(776), + @log_table_severe_errors nvarchar(776), + @log_table_pending_tasks nvarchar(776), + @log_table_blocking nvarchar(776), + @log_table_deadlocks nvarchar(776), @cleanup_date datetime2(7), @check_sql nvarchar(MAX) = N'', @create_sql nvarchar(MAX) = N'', @@ -391,8 +408,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. {cross_apply} OPTION(RECOMPILE); ', + /* + {table_check_literal} and {table_check} carry the same table + name, but one lands inside a quoted string and the other is an + identifier. A name containing a single quote (legal in delimited + identifiers) is fine in the identifier position and fatal in the + literal one unless its quotes are doubled — so the literal + placeholder gets the quote-doubled value at every substitution + site, and they cannot share a placeholder. + */ @mdsql_template = N' - IF OBJECT_ID(''{table_check}'', ''U'') IS NOT NULL + IF OBJECT_ID(''{table_check_literal}'', ''U'') IS NOT NULL BEGIN SELECT @max_event_time = @@ -515,6 +541,8 @@ AND ca.utc_timestamp < @end_date'; @log_database_name = ISNULL(@log_database_name, DB_NAME()), /* Default schema name to dbo if not specified */ @log_schema_name = ISNULL(@log_schema_name, N'dbo'), + /* An explicit NULL prefix walks past the length guard and QUOTENAME(NULL) silently no-ops every logging table */ + @log_table_name_prefix = ISNULL(@log_table_name_prefix, N'HealthParser'), @log_retention_days = CASE WHEN @log_retention_days < 0 @@ -522,6 +550,19 @@ AND ca.utc_timestamp < @end_date'; ELSE @log_retention_days END; + /* + QUOTENAME returns NULL for any input over 128 characters, and a + NULL name turns @create_sql NULL, which sys.sp_executesql runs + as a silent no-op — no table, no error, no logging. The longest + suffix appended to the prefix is _SignificantWaits (17), so the + prefix must leave room for it. Fail loudly up front instead. + */ + IF LEN(@log_table_name_prefix) > 111 + BEGIN + RAISERROR('@log_table_name_prefix is limited to 111 characters, so the longest table name suffix (_SignificantWaits) still fits in an identifier. Shorten it and run me again.', 11, 1) WITH NOWAIT; + RETURN; + END; + /* Validate database exists */ IF NOT EXISTS ( @@ -531,7 +572,7 @@ AND ca.utc_timestamp < @end_date'; WHERE d.name = @log_database_name ) BEGIN - RAISERROR('The specified logging database %s does not exist. Logging will be disabled.', 11, 1, @log_database_name) WITH NOWAIT; + RAISERROR('The specified logging database %s does not exist. Fix @log_database_name and run me again.', 11, 1, @log_database_name) WITH NOWAIT; RETURN; END; @@ -638,7 +679,7 @@ AND ca.utc_timestamp < @end_date'; session_id integer NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for significant waits logging.'', 0, 1, ''' + @log_table_significant_waits + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for significant waits logging.'', 0, 1, ''' + REPLACE(@log_table_significant_waits, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -674,7 +715,7 @@ AND ca.utc_timestamp < @end_date'; max_wait_time_ms nvarchar(30) NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by count logging.'', 0, 1, ''' + @log_table_waits_by_count + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by count logging.'', 0, 1, ''' + REPLACE(@log_table_waits_by_count, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -709,7 +750,7 @@ AND ca.utc_timestamp < @end_date'; max_wait_time_ms nvarchar(30) NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by duration logging.'', 0, 1, ''' + @log_table_waits_by_duration + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for waits by duration logging.'', 0, 1, ''' + REPLACE(@log_table_waits_by_duration, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -747,7 +788,7 @@ AND ca.utc_timestamp < @end_date'; longestPendingRequests_filePath nvarchar(500) NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for IO issues logging.'', 0, 1, ''' + @log_table_io_issues + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for IO issues logging.'', 0, 1, ''' + REPLACE(@log_table_io_issues, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -789,7 +830,7 @@ AND ca.utc_timestamp < @end_date'; didBlockingOccur bit NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU tasks logging.'', 0, 1, ''' + @log_table_cpu_tasks + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU tasks logging.'', 0, 1, ''' + REPLACE(@log_table_cpu_tasks, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -852,7 +893,7 @@ AND ca.utc_timestamp < @end_date'; last_os_error bigint NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory conditions logging.'', 0, 1, ''' + @log_table_memory_conditions + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory conditions logging.'', 0, 1, ''' + REPLACE(@log_table_memory_conditions, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -896,7 +937,7 @@ AND ca.utc_timestamp < @end_date'; notification nvarchar(256) NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory broker logging.'', 0, 1, ''' + @log_table_memory_broker + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory broker logging.'', 0, 1, ''' + REPLACE(@log_table_memory_broker, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -955,7 +996,7 @@ AND ca.utc_timestamp < @end_date'; is_process_virtual_memory_low nvarchar(10) NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory node OOM logging.'', 0, 1, ''' + @log_table_memory_node_oom + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory node OOM logging.'', 0, 1, ''' + REPLACE(@log_table_memory_node_oom, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1002,7 +1043,7 @@ AND ca.utc_timestamp < @end_date'; BadPagesFixed bigint NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for system health logging.'', 0, 1, ''' + @log_table_system_health + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for system health logging.'', 0, 1, ''' + REPLACE(@log_table_system_health, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1016,6 +1057,31 @@ AND ca.utc_timestamp < @end_date'; /* Create SchedulerIssues table if it doesn't exist */ SET @create_sql = N' + /* + Old versions of this table were shaped around per-scheduler + columns (scheduler_id, status, ...) that the scheduler event + never actually carried, so nothing was ever inserted into + them — the collection filtered on an event name that does + not exist. That makes dropping an old-shape table lossless, + and it has to happen or the reshaped INSERT fails against it. + */ + IF EXISTS + ( + SELECT + 1/0 + FROM ' + QUOTENAME(@log_database_name) + N'.sys.tables AS t + JOIN ' + QUOTENAME(@log_database_name) + N'.sys.schemas AS s + ON s.schema_id = t.schema_id + JOIN ' + QUOTENAME(@log_database_name) + N'.sys.columns AS c + ON c.object_id = t.object_id + WHERE t.name = @table_name + N''_SchedulerIssues'' + AND s.name = @schema_name + AND c.name = N''scheduler_id'' + ) + BEGIN + DROP TABLE ' + @log_table_scheduler_issues + N'; + END; + IF NOT EXISTS ( SELECT @@ -1032,17 +1098,15 @@ AND ca.utc_timestamp < @end_date'; id bigint IDENTITY, collection_time datetime2(7) NOT NULL DEFAULT SYSDATETIME(), event_time datetime2(7) NULL, - scheduler_id integer NULL, - cpu_id integer NULL, - status nvarchar(256) NULL, - is_online bit NULL, - is_runnable bit NULL, - is_running bit NULL, - non_yielding_time_ms nvarchar(30) NULL, - thread_quantum_ms nvarchar(30) NULL, + sql_cpu_utilization integer NULL, + other_process_cpu integer NULL, + system_idle integer NULL, + memory_utilization integer NULL, + page_faults nvarchar(30) NULL, + working_set_delta_mb decimal(19, 2) NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for scheduler issues logging.'', 0, 1, ''' + @log_table_scheduler_issues + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for scheduler issues logging.'', 0, 1, ''' + REPLACE(@log_table_scheduler_issues, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1080,7 +1144,7 @@ AND ca.utc_timestamp < @end_date'; database_id integer NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for severe errors logging.'', 0, 1, ''' + @log_table_severe_errors + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for severe errors logging.'', 0, 1, ''' + REPLACE(@log_table_severe_errors, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1119,7 +1183,7 @@ AND ca.utc_timestamp < @end_date'; entry_point_count bigint NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for pending tasks logging.'', 0, 1, ''' + @log_table_pending_tasks + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for pending tasks logging.'', 0, 1, ''' + REPLACE(@log_table_pending_tasks, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1186,7 +1250,7 @@ AND ca.utc_timestamp < @end_date'; blocked_process_report xml NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for blocking logging.'', 0, 1, ''' + @log_table_blocking + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for blocking logging.'', 0, 1, ''' + REPLACE(@log_table_blocking, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1240,7 +1304,7 @@ AND ca.utc_timestamp < @end_date'; deadlock_graph xml NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for deadlocks logging.'', 0, 1, ''' + @log_table_deadlocks + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for deadlocks logging.'', 0, 1, ''' + REPLACE(@log_table_deadlocks, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1380,15 +1444,35 @@ AND ca.utc_timestamp < @end_date'; END FROM ( + /* + Every area an output section reads under must have a row here for + every event that section consumes, or that section silently returns + nothing for that @what_to_check value — the readers do not collect + for themselves. Multiple rows may share a temp_table safely: the + collection cursor marks the whole table collected after its first + pass, so it is still read exactly once. + + The scheduler event's real name is + scheduler_monitor_system_health_ring_buffer_recorded — the short + name this used to filter on matches nothing, on any version, so the + scheduler section had never returned a single row. The scheduler + output runs under 'system' and 'cpu', and the CPU worker-thread, + pending-task, and memory sections all read + sp_server_diagnostics_component_result, so 'cpu' and 'memory' need + rows for it too. + */ VALUES - ('cpu', 'scheduler_monitor_system_health', '#scheduler_monitor', 'scheduler_monitor'), + ('cpu', 'scheduler_monitor_system_health_ring_buffer_recorded', '#scheduler_monitor', 'scheduler_monitor'), + ('cpu', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'), ('disk', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'), ('locking', 'xml_deadlock_report', '#xml_deadlock_report', 'xml_deadlock_report'), ('locking', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'), ('waits', 'wait_info', '#wait_info', 'wait_info'), ('waits', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'), + ('system', 'scheduler_monitor_system_health_ring_buffer_recorded', '#scheduler_monitor', 'scheduler_monitor'), ('system', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'), ('system', 'error_reported', '#error_reported', 'error_reported'), + ('memory', 'sp_server_diagnostics_component_result', '#sp_server_diagnostics_component_result', 'sp_server_diagnostics_component_result'), ('memory', 'memory_broker_ring_buffer_recorded', '#memory_broker', 'memory_broker'), ('memory', 'memory_node_oom_ring_buffer_recorded', '#memory_node_oom', 'memory_node_oom') ) AS v(area_name, object_name, temp_table, insert_list); @@ -1736,7 +1820,15 @@ AND ca.utc_timestamp < @end_date'; WHERE e.x.exist('@name[.= "wait_info"]') = 1 OPTION(RECOMPILE); END; - IF @what_to_check IN ('all', 'disk', 'locking', 'system', 'memory') + /* + 'cpu' and 'waits' belong here too: the worker-thread and + pending-task sections read this table under 'cpu', and the + waits-by-count/duration sections read it under 'waits' — the + file-target routing table collects it for both, and this guard + has to stay in step or the ring-buffer path silently returns + nothing for those checks. + */ + IF @what_to_check IN ('all', 'cpu', 'disk', 'locking', 'system', 'memory', 'waits') BEGIN IF @debug = 1 BEGIN @@ -1806,7 +1898,7 @@ AND ca.utc_timestamp < @end_date'; e.x.query('.') FROM #ring_buffer AS rb CROSS APPLY rb.ring_buffer.nodes('/event') AS e(x) - WHERE e.x.exist('@name[.= "scheduler_monitor_system_health"]') = 1 + WHERE e.x.exist('@name[.= "scheduler_monitor_system_health_ring_buffer_recorded"]') = 1 OPTION(RECOMPILE); END; @@ -2092,7 +2184,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_significant_waits, N'''', N'''''') + ), '{table_check}', @log_table_significant_waits ), @@ -2363,7 +2460,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_waits_by_count, N'''', N'''''') + ), '{table_check}', @log_table_waits_by_count ), @@ -2653,7 +2755,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_waits_by_duration, N'''', N'''''') + ), '{table_check}', @log_table_waits_by_duration ), @@ -2864,7 +2971,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_io_issues, N'''', N'''''') + ), '{table_check}', @log_table_io_issues ), @@ -3063,7 +3175,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_cpu_tasks, N'''', N'''''') + ), '{table_check}', @log_table_cpu_tasks ), @@ -3289,7 +3406,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_pending_tasks, N'''', N'''''') + ), '{table_check}', @log_table_pending_tasks ), @@ -3514,7 +3636,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_memory_conditions, N'''', N'''''') + ), '{table_check}', @log_table_memory_conditions ), @@ -3814,7 +3941,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_memory_broker, N'''', N'''''') + ), '{table_check}', @log_table_memory_broker ), @@ -4209,7 +4341,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_memory_node_oom, N'''', N'''''') + ), '{table_check}', @log_table_memory_node_oom ), @@ -4424,7 +4561,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_system_health, N'''', N'''''') + ), '{table_check}', @log_table_system_health ), @@ -4512,6 +4654,23 @@ AND ca.utc_timestamp < @end_date'; RAISERROR('Parsing scheduler monitor data', 0, 0) WITH NOWAIT; END; + /* + The scheduler_monitor_system_health_ring_buffer_recorded event + is a utilization sample, not a per-scheduler status record. Its + data fields are process_utilization (SQL Server's CPU%), + system_idle (idle CPU%), memory_utilization (percent of + committed memory in the working set — 100 is healthy, low means + the working set got trimmed), page_faults, and + working_set_delta (bytes). The previous version of this section + parsed scheduler_id / status / is_online and friends, which + this event has never carried — every column came back NULL, + and its WARNING filter matched nothing, ever. + + A sample is a warning when SQL Server's CPU is pinned, when + other processes are eating half the box, or when the working + set has been cut down — the classic external memory pressure + signal. + */ SELECT event_time = DATEADD @@ -4525,19 +4684,38 @@ AND ca.utc_timestamp < @end_date'; ), w.x.value('@timestamp', 'datetime2') ), - scheduler_id = w.x.value('(data[@name="scheduler_id"]/value)[1]', 'integer'), - cpu_id = w.x.value('(data[@name="cpu_id"]/value)[1]', 'integer'), - status = w.x.value('(data[@name="status"]/text)[1]', 'nvarchar(256)'), - is_online = w.x.value('(data[@name="is_online"]/value)[1]', 'bit'), - is_runnable = w.x.value('(data[@name="is_runnable"]/value)[1]', 'bit'), - is_running = w.x.value('(data[@name="is_running"]/value)[1]', 'bit'), - non_yielding_time_ms = w.x.value('(data[@name="non_yielding_time"]/value)[1]', 'bigint'), - thread_quantum_ms = w.x.value('(data[@name="thread_quantum"]/value)[1]', 'bigint'), + sql_cpu_utilization = v.sql_cpu_utilization, + other_process_cpu = + (100 - v.sql_cpu_utilization - v.system_idle), + system_idle = v.system_idle, + memory_utilization = v.memory_utilization, + page_faults = v.page_faults, + working_set_delta_mb = v.working_set_delta_mb, xml = w.x.query('.') INTO #scheduler_issues FROM #scheduler_monitor AS sm CROSS APPLY sm.scheduler_monitor.nodes('//event') AS w(x) - WHERE (w.x.exist('(data[@name="status"]/text[.= "WARNING"])') = @warnings_only OR @warnings_only = 0) + CROSS APPLY + ( + SELECT + sql_cpu_utilization = w.x.value('(data[@name="process_utilization"]/value)[1]', 'integer'), + system_idle = w.x.value('(data[@name="system_idle"]/value)[1]', 'integer'), + memory_utilization = w.x.value('(data[@name="memory_utilization"]/value)[1]', 'integer'), + page_faults = w.x.value('(data[@name="page_faults"]/value)[1]', 'bigint'), + working_set_delta_mb = + CONVERT + ( + decimal(19, 2), + w.x.value('(data[@name="working_set_delta"]/value)[1]', 'bigint') / 1048576. + ) + ) AS v + WHERE + ( + @warnings_only = 0 + OR v.sql_cpu_utilization >= 90 + OR (100 - v.sql_cpu_utilization - v.system_idle) >= 50 + OR v.memory_utilization <= 50 + ) OPTION(RECOMPILE, MAXDOP 1); IF @debug = 1 @@ -4589,17 +4767,15 @@ AND ca.utc_timestamp < @end_date'; ' + CASE WHEN @log_to_table = 1 THEN N'' - ELSE N'finding = ''scheduler monitor issues'',' + ELSE N'finding = ''scheduler monitor health'',' END + N' si.event_time, - si.scheduler_id, - si.cpu_id, - si.status, - si.is_online, - si.is_runnable, - si.is_running, - non_yielding_time_ms = + si.sql_cpu_utilization, + si.other_process_cpu, + si.system_idle, + si.memory_utilization, + page_faults = REPLACE ( CONVERT @@ -4608,29 +4784,14 @@ AND ca.utc_timestamp < @end_date'; CONVERT ( money, - si.non_yielding_time_ms + si.page_faults ), 1 ), N''.00'', N'''' ), - thread_quantum_ms = - REPLACE - ( - CONVERT - ( - nvarchar(30), - CONVERT - ( - money, - si.thread_quantum_ms - ), - 1 - ), - N''.00'', - N'''' - ) + si.working_set_delta_mb FROM #scheduler_issues AS si'; /* Add the WHERE clause only for table logging */ @@ -4642,7 +4803,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_scheduler_issues, N'''', N'''''') + ), '{table_check}', @log_table_scheduler_issues ), @@ -4679,14 +4845,12 @@ AND ca.utc_timestamp < @end_date'; ' + @log_table_scheduler_issues + N' ( event_time, - scheduler_id, - cpu_id, - status, - is_online, - is_runnable, - is_running, - non_yielding_time_ms, - thread_quantum_ms + sql_cpu_utilization, + other_process_cpu, + system_idle, + memory_utilization, + page_faults, + working_set_delta_mb )' + @dsql; @@ -4750,8 +4914,15 @@ AND ca.utc_timestamp < @end_date'; severity = w.x.value('(data[@name="severity"]/value)[1]', 'integer'), state = w.x.value('(data[@name="state"]/value)[1]', 'integer'), message = w.x.value('(data[@name="message"]/value)[1]', 'nvarchar(max)'), - database_name = DB_NAME(w.x.value('(data[@name="database_id"]/value)[1]', 'integer')), - database_id = w.x.value('(data[@name="database_id"]/value)[1]', 'integer'), + /* + database_id rides on error_reported as an ACTION, not a + data element — reading the data axis returns NULL for every + event, which blanked these two columns for all severe + errors. session_id further up reads the action axis the + same way. + */ + database_name = DB_NAME(w.x.value('(action[@name="database_id"]/value)[1]', 'integer')), + database_id = w.x.value('(action[@name="database_id"]/value)[1]', 'integer'), xml = w.x.query('.') INTO #error_info FROM #error_reported AS er @@ -4837,7 +5008,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_severe_errors, N'''', N'''''') + ), '{table_check}', @log_table_severe_errors ), @@ -5226,39 +5402,56 @@ AND ca.utc_timestamp < @end_date'; THEN ( SELECT + /* + ISNULL because either metadata function + returns NULL when the database or object + no longer resolves (dropped, offline, + detached since the event fired) — and a + NULL here nulls the whole concatenation, + rendering the query column blank. The + raw text still carries the database and + object ids, which beats an empty cell. + COALESCE, not ISNULL: ISNULL types the + result as its first argument and cuts + the fallback text to 257 characters. + */ [processing-instruction(query)] = - OBJECT_SCHEMA_NAME + COALESCE ( - SUBSTRING - ( - kheb.query_text, - CHARINDEX(N'Object Id = ', kheb.query_text) + 12, - LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12) - ) - , - SUBSTRING - ( - kheb.query_text, - CHARINDEX(N'Database Id = ', kheb.query_text) + 14, - CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14) - ) - ) + - N'.' + - OBJECT_NAME - ( - SUBSTRING - ( - kheb.query_text, - CHARINDEX(N'Object Id = ', kheb.query_text) + 12, - LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12) - ) - , - SUBSTRING - ( - kheb.query_text, - CHARINDEX(N'Database Id = ', kheb.query_text) + 14, - CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14) - ) + OBJECT_SCHEMA_NAME + ( + SUBSTRING + ( + kheb.query_text, + CHARINDEX(N'Object Id = ', kheb.query_text) + 12, + LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12) + ) + , + SUBSTRING + ( + kheb.query_text, + CHARINDEX(N'Database Id = ', kheb.query_text) + 14, + CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14) + ) + ) + + N'.' + + OBJECT_NAME + ( + SUBSTRING + ( + kheb.query_text, + CHARINDEX(N'Object Id = ', kheb.query_text) + 12, + LEN(kheb.query_text) - (CHARINDEX(N'Object Id = ', kheb.query_text) + 12) + ) + , + SUBSTRING + ( + kheb.query_text, + CHARINDEX(N'Database Id = ', kheb.query_text) + 14, + CHARINDEX(N'Object Id', kheb.query_text) - (CHARINDEX(N'Database Id = ', kheb.query_text) + 14) + ) + ), + kheb.query_text ) FOR XML PATH(N''), @@ -5438,7 +5631,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_blocking, N'''', N'''''') + ), '{table_check}', @log_table_blocking ), @@ -5543,21 +5741,29 @@ AND ca.utc_timestamp < @end_date'; END; ELSE BEGIN - SELECT - finding = CASE - WHEN @what_to_check NOT IN ('all', 'locking') - THEN 'blocking skipped, @what_to_check set to ' + @what_to_check - WHEN @skip_locks = 1 - THEN 'blocking skipped, @skip_locks set to 1' - WHEN @what_to_check IN ('all', 'locking') - THEN 'no blocking found between ' + - RTRIM(CONVERT(date, @start_date)) + - ' and ' + - RTRIM(CONVERT(date, @end_date)) + - ' with @warnings_only set to ' + - RTRIM(@warnings_only) - ELSE 'no blocking found!' - END; + /* + No-results messages are client output, and @log_to_table + documents itself as log-instead-of-return — same guard the + populated path above already uses. + */ + IF @log_to_table = 0 + BEGIN + SELECT + finding = CASE + WHEN @what_to_check NOT IN ('all', 'locking') + THEN 'blocking skipped, @what_to_check set to ' + @what_to_check + WHEN @skip_locks = 1 + THEN 'blocking skipped, @skip_locks set to 1' + WHEN @what_to_check IN ('all', 'locking') + THEN 'no blocking found between ' + + RTRIM(CONVERT(date, @start_date)) + + ' and ' + + RTRIM(CONVERT(date, @end_date)) + + ' with @warnings_only set to ' + + RTRIM(@warnings_only) + ELSE 'no blocking found!' + END; + END; END; /*Grab available plans from the cache*/ @@ -5825,39 +6031,54 @@ AND ca.utc_timestamp < @end_date'; THEN ( SELECT + /* + Same ISNULL guard as the blocking + twin above: a dropped or offline + database/object NULLs the metadata + functions, and the concatenation + with them — falling back to the + raw text keeps the ids visible. + COALESCE, not ISNULL: ISNULL types + the result as its first argument and + cuts the fallback to 257 characters. + */ [processing-instruction(query)] = - OBJECT_SCHEMA_NAME + COALESCE ( - SUBSTRING - ( - dp.query_text, - CHARINDEX(N''Object Id = '', dp.query_text) + 12, - LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12) - ) - , - SUBSTRING - ( - dp.query_text, - CHARINDEX(N''Database Id = '', dp.query_text) + 14, - CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14) - ) - ) + - N''.'' + - OBJECT_NAME - ( - SUBSTRING - ( - dp.query_text, - CHARINDEX(N''Object Id = '', dp.query_text) + 12, - LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12) - ) - , - SUBSTRING - ( - dp.query_text, - CHARINDEX(N''Database Id = '', dp.query_text) + 14, - CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14) - ) + OBJECT_SCHEMA_NAME + ( + SUBSTRING + ( + dp.query_text, + CHARINDEX(N''Object Id = '', dp.query_text) + 12, + LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12) + ) + , + SUBSTRING + ( + dp.query_text, + CHARINDEX(N''Database Id = '', dp.query_text) + 14, + CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14) + ) + ) + + N''.'' + + OBJECT_NAME + ( + SUBSTRING + ( + dp.query_text, + CHARINDEX(N''Object Id = '', dp.query_text) + 12, + LEN(dp.query_text) - (CHARINDEX(N''Object Id = '', dp.query_text) + 12) + ) + , + SUBSTRING + ( + dp.query_text, + CHARINDEX(N''Database Id = '', dp.query_text) + 14, + CHARINDEX(N''Object Id'', dp.query_text) - (CHARINDEX(N''Database Id = '', dp.query_text) + 14) + ) + ), + dp.query_text ) FOR XML PATH(N''''), @@ -5902,7 +6123,12 @@ AND ca.utc_timestamp < @end_date'; ( REPLACE ( - @mdsql_template, + REPLACE + ( + @mdsql_template, + '{table_check_literal}', + REPLACE(@log_table_deadlocks, N'''', N'''''') + ), '{table_check}', @log_table_deadlocks ), @@ -5990,19 +6216,26 @@ AND ca.utc_timestamp < @end_date'; END; ELSE BEGIN - SELECT - finding = CASE - WHEN @what_to_check NOT IN ('all', 'locking') - THEN 'deadlocks skipped, @what_to_check set to ' + @what_to_check - WHEN @skip_locks = 1 - THEN 'deadlocks skipped, @skip_locks set to 1' - WHEN @what_to_check IN ('all', 'locking') - THEN 'no deadlocks found between ' + - RTRIM(CONVERT(date, @start_date)) + - ' and ' + - RTRIM(CONVERT(date, @end_date)) - ELSE 'no deadlocks found!' - END; + /* + Client output only — same @log_to_table guard as the + blocking twin above. + */ + IF @log_to_table = 0 + BEGIN + SELECT + finding = CASE + WHEN @what_to_check NOT IN ('all', 'locking') + THEN 'deadlocks skipped, @what_to_check set to ' + @what_to_check + WHEN @skip_locks = 1 + THEN 'deadlocks skipped, @skip_locks set to 1' + WHEN @what_to_check IN ('all', 'locking') + THEN 'no deadlocks found between ' + + RTRIM(CONVERT(date, @start_date)) + + ' and ' + + RTRIM(CONVERT(date, @end_date)) + ELSE 'no deadlocks found!' + END; + END; END; IF @debug = 1 @@ -6209,65 +6442,74 @@ AND ca.utc_timestamp < @end_date'; ap.avg_worker_time_ms DESC OPTION(RECOMPILE, MAXDOP 1); - IF EXISTS - ( - SELECT - 1/0 - FROM #all_available_plans AS ap - WHERE ap.finding = 'available plans for blocking' - ) - BEGIN - SELECT - aap.* - FROM #all_available_plans AS aap - WHERE aap.finding = 'available plans for blocking' - ORDER BY - aap.avg_worker_time_ms DESC - OPTION(RECOMPILE); - END; - ELSE + /* + The available-plans grids and their no-plans messages are client + output with no logging counterpart, so the documented + log-instead-of-return contract suppresses the lot when + @log_to_table = 1. + */ + IF @log_to_table = 0 BEGIN - /* Only show this message if we found blocking but no plans */ IF EXISTS ( SELECT 1/0 - FROM #blocks AS b + FROM #all_available_plans AS ap + WHERE ap.finding = 'available plans for blocking' ) BEGIN SELECT - finding = 'no cached plans found for blocking queries'; + aap.* + FROM #all_available_plans AS aap + WHERE aap.finding = 'available plans for blocking' + ORDER BY + aap.avg_worker_time_ms DESC + OPTION(RECOMPILE); + END; + ELSE + BEGIN + /* Only show this message if we found blocking but no plans */ + IF EXISTS + ( + SELECT + 1/0 + FROM #blocks AS b + ) + BEGIN + SELECT + finding = 'no cached plans found for blocking queries'; + END; END; - END; - IF EXISTS - ( - SELECT - 1/0 - FROM #all_available_plans AS ap - WHERE ap.finding = 'available plans for deadlocks' - ) - BEGIN - SELECT - aap.* - FROM #all_available_plans AS aap - WHERE aap.finding = 'available plans for deadlocks' - ORDER BY - aap.avg_worker_time_ms DESC - OPTION(RECOMPILE); - END; - ELSE - BEGIN - /* Only show this message if we found deadlocks but no plans */ IF EXISTS ( SELECT 1/0 - FROM #deadlocks_parsed AS dp + FROM #all_available_plans AS ap + WHERE ap.finding = 'available plans for deadlocks' ) BEGIN SELECT - finding = 'no cached plans found for deadlock queries'; + aap.* + FROM #all_available_plans AS aap + WHERE aap.finding = 'available plans for deadlocks' + ORDER BY + aap.avg_worker_time_ms DESC + OPTION(RECOMPILE); + END; + ELSE + BEGIN + /* Only show this message if we found deadlocks but no plans */ + IF EXISTS + ( + SELECT + 1/0 + FROM #deadlocks_parsed AS dp + ) + BEGIN + SELECT + finding = 'no cached plans found for deadlock queries'; + END; END; END; END; /*End locks*/ diff --git a/sp_HumanEvents/README.md b/sp_HumanEvents/README.md index 0a7809cd..b0f07de0 100644 --- a/sp_HumanEvents/README.md +++ b/sp_HumanEvents/README.md @@ -47,9 +47,10 @@ Misuse of this procedure can harm performance. Be very careful about introducing | parameter | data_type | description | valid_inputs | defaults | |------------------------|----------------|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------|-------------------------------------------------| | @event_type | sysname | used to pick which session you want to run | "blocking", "query", "waits", "recompiles", "compiles" and certain variations on those words | "query" | -| @query_duration_ms | integer | (>=) used to set a minimum query duration to collect data for | an integer | 500 (ms) | +| @query_duration_ms | decimal | (>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration | a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations | 500 (ms) | | @query_sort_order | nvarchar | when you use the "query" event, lets you choose which metrics to sort results by | "cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time" | "cpu" | | @skip_plans | bit | when you use the "query" event, lets you skip collecting actual execution plans |1 or 0 | 0 | +| @keep_prepare_rpc | bit | when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare) that are normally filtered out; shows whether a driver is preparing and reusing statements, e.g. pyodbc's fast_executemany | 1 or 0 | 0 | | @blocking_duration_ms | integer | (>=) used to set a minimum blocking duration to collect data for | an integer | 500 (ms) | | @wait_type | nvarchar | (inclusive) filter to only specific wait types | a single wait type, or a CSV list of wait types | "all", which uses a list of "interesting" waits | | @wait_duration_ms | integer | (>=) used to set a minimum time per wait to collect data for | an integer | 10 (ms) | @@ -63,7 +64,7 @@ Misuse of this procedure can harm performance. Be very careful about introducing | @object_schema | sysname | (inclusive) the schema of the object you want to filter to; only needed with blocking events | a stringy thing | dbo | | @requested_memory_mb | integer | (>=) the memory grant a query must ask for to have data collected | an integer | 0 | | @seconds_sample | tinyint | the duration in seconds to run the event session for | an integer | 10 | -| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits | 1 or 0 | 0 | +| @gimme_danger | bit | used to override default duration minimums for wait events, including zero-duration waits. only use if you're okay with potentially adding a lot of observer overhead on your system, or for testing purposes. | 1 or 0 | 0 | | @target_output | sysname | output target for extended events | "ring_buffer" or "event_file" (event_file not available for Azure SQL DB or Managed Instance) | "ring_buffer" | | @keep_alive | bit | creates a permanent session, either to watch live or log to a table from | 1 or 0 | 0 | | @custom_name | sysname | if you want to custom name a permanent session | a stringy thing | intentionally left blank | @@ -97,6 +98,14 @@ EXECUTE dbo.sp_HumanEvents @event_type = 'waits', @database_name = 'YourDatabase'; +-- Diagnose whether a client driver is preparing and reusing statements +-- (e.g. pyodbc's fast_executemany). Adds a result set of the raw +-- sp_prepare/sp_prepexec/sp_execute RPC calls that are normally hidden. +EXECUTE dbo.sp_HumanEvents + @event_type = 'query', + @keep_prepare_rpc = 1, + @query_duration_ms = 0; + -- Set up a permanent session for logging EXECUTE dbo.sp_HumanEvents @event_type = 'query', @@ -178,7 +187,7 @@ ON SERVER | parameter_name | data_type | description | valid_inputs | defaults | |-----------------------|-----------|----------------------------------------------------|--------------------------------------------------------------------------------------------------|------------------------------------| | @session_name | sysname | name of the extended event session to pull from | extended event session name capturing sqlserver.blocked_process_report, system_health also works | keeper_HumanEvents_blocking | -| @target_type | sysname | target of the extended event session | event_file or ring_buffer or table | NULL | +| @target_type | sysname | target type of the extended event session (ring buffer, file) or 'table' to read from a table | event_file or ring_buffer or table | NULL | | @start_date | datetime2 | filter by date | a reasonable date | NULL; will shortcut to last 7 days | | @end_date | datetime2 | filter by date | a reasonable date | NULL | | @database_name | sysname | filter by database name | a database that exists on this server | NULL | @@ -186,14 +195,15 @@ ON SERVER | @target_database | sysname | database containing the table with BPR data | a valid database name | NULL | | @target_schema | sysname | schema of the table | a valid schema name | NULL | | @target_table | sysname | table name | a valid table name | NULL | -| @target_column | sysname | column containing XML data | a valid column name | NULL | -| @timestamp_column | sysname | column containing timestamp (optional) | a valid column name | NULL | -| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 | -| @log_database_name | sysname | database to store logging tables | a valid database name | NULL | -| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL | +| @target_column | sysname | column containing XML data | an XML column containing blocked process report data | NULL | +| @timestamp_column | sysname | column containing UTC timestamp for filtering (optional). MUST be stored in UTC — @start_date and @end_date are shifted to UTC internally to match the XML @timestamp attribute, and the same UTC-shifted values are used for this column filter. A column in local time will be filtered against the wrong window. | a datetime / datetime2 / datetimeoffset column storing UTC timestamps | NULL | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | a valid database name | NULL (current database) | +| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL (dbo) | | @log_table_name_prefix| sysname | prefix for all logging tables | a valid table name prefix | 'HumanEventsBlockViewer' | | @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | a valid integer | 30 | -| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 | 5000 | +| @max_blocking_events | integer | maximum blocking events to analyze, 0 = unlimited | 0 to 2147483647 (0 = unlimited) | 5000 | +| @skip_execution_plans | bit | skip gathering and returning the execution plans result set and jump straight to the findings rollup | 0 or 1 | 0 | | @help | bit | how you got here | 0 or 1 | 0 | | @debug | bit | dumps raw temp table contents | 0 or 1 | 0 | | @version | varchar | OUTPUT; for support | none; OUTPUT | none; OUTPUT | diff --git a/sp_HumanEvents/sp_HumanEvents.sql b/sp_HumanEvents/sp_HumanEvents.sql index aacb60cb..fd9e26cb 100644 --- a/sp_HumanEvents/sp_HumanEvents.sql +++ b/sp_HumanEvents/sp_HumanEvents.sql @@ -50,9 +50,10 @@ ALTER PROCEDURE dbo.sp_HumanEvents ( @event_type sysname = N'query', - @query_duration_ms integer = 500, + @query_duration_ms decimal(18,3) = 500, @query_sort_order nvarchar(20) = N'cpu', @skip_plans bit = 0, + @keep_prepare_rpc bit = 0, /*adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare, sp_cursor, sp_describe_undeclared_parameters) the query_hash filter hides; helps diagnose driver batching like pyodbc fast_executemany. these RPCs run sub-millisecond, so seeing anything requires lowering @query_duration_ms to 0 or a fractional value; at the default of 500 the session filter drops them all*/ @blocking_duration_ms integer = 500, @wait_type nvarchar(4000) = N'ALL', @wait_duration_ms integer = 10, @@ -88,8 +89,8 @@ SET XACT_ABORT ON; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - @version = '7.7', - @version_date = '20260701'; + @version = '7.8', + @version_date = '20260801'; IF @help = 1 BEGIN @@ -138,9 +139,10 @@ BEGIN description = CASE ap.name WHEN N'@event_type' THEN N'used to pick which session you want to run' - WHEN N'@query_duration_ms' THEN N'(>=) used to set a minimum query duration to collect data for' + WHEN N'@query_duration_ms' THEN N'(>=) used to set a minimum query duration (milliseconds) to collect data for; 0 removes the floor and collects every duration' WHEN N'@query_sort_order' THEN 'when you use the "query" event, lets you choose which metrics to sort results by' WHEN N'@skip_plans' THEN 'when you use the "query" event, lets you skip collecting actual execution plans' + WHEN N'@keep_prepare_rpc' THEN N'when you use the "query" event, adds a result set of the prepared-statement RPC calls (sp_prepare, sp_prepexec, sp_execute, sp_unprepare, anything matching sp_cursor%, and sp_describe_undeclared_parameters) that are normally filtered out; the only reliable way to tell whether a client driver is preparing and reusing statements, e.g. pyodbc''s fast_executemany. these calls almost always finish in well under a millisecond, so you have to lower @query_duration_ms (to 0, or a fractional value like 0.1) to get any rows back; at the default of 500 the event session filters them out before they are ever collected' WHEN N'@blocking_duration_ms' THEN N'(>=) used to set a minimum blocking duration to collect data for' WHEN N'@wait_type' THEN N'(inclusive) filter to only specific wait types' WHEN N'@wait_duration_ms' THEN N'(>=) used to set a minimum time per wait to collect data for' @@ -172,9 +174,10 @@ BEGIN valid_inputs = CASE ap.name WHEN N'@event_type' THEN N'"blocking", "query", "waits", "recompiles", "compiles" and certain variations on those words' - WHEN N'@query_duration_ms' THEN N'an integer' + WHEN N'@query_duration_ms' THEN N'a decimal; fractional milliseconds allowed (e.g. 0.5 = 500 microseconds), 0 captures all durations' WHEN N'@query_sort_order' THEN '"cpu", "reads", "writes", "duration", "memory", or "spills" (any of which you can prefix with "avg" to sort by averages, e.g. "avg cpu"), or "event_time"' WHEN N'@skip_plans' THEN '1 or 0' + WHEN N'@keep_prepare_rpc' THEN N'1 or 0' WHEN N'@blocking_duration_ms' THEN N'an integer' WHEN N'@wait_type' THEN N'a single wait type, or a CSV list of wait types' WHEN N'@wait_duration_ms' THEN N'an integer' @@ -209,6 +212,7 @@ BEGIN WHEN N'@query_duration_ms' THEN N'500 (ms)' WHEN N'@query_sort_order' THEN N'"cpu"' WHEN N'@skip_plans' THEN '0' + WHEN N'@keep_prepare_rpc' THEN N'0' WHEN N'@blocking_duration_ms' THEN N'500 (ms)' WHEN N'@wait_type' THEN N'"all", which uses a list of "interesting" waits' WHEN N'@wait_duration_ms' THEN N'10 (ms)' @@ -513,6 +517,15 @@ DECLARE @max_id integer, @event_type_check sysname, @object_name_check nvarchar(1000) = N'', + /* + The parameterization table's fully-quoted name. Built separately + because the _parameterization suffix has to land INSIDE the leaf + QUOTENAME — appending it after @object_name_check produced + [db].[schema].[table]_parameterization, which is not a valid name, + and broke every compiles-to-table run on SQL Server 2017+ with a + syntax error. + */ + @param_table_check nvarchar(1000) = N'', @table_sql nvarchar(max) = N'', @view_tracker bit, @spe nvarchar(max) = N'.sys.sp_executesql ', @@ -565,7 +578,20 @@ IF NOT EXISTS RETURN; END; -/*clean up any old/dormant sessions*/ +/* +Clean up any old/dormant sessions. + +The pattern is anchored to the prefix and the literal underscore is +escaped with a bracket class. The old N'HumanEvents%' treated _ as a +single-character wildcard and had nothing after the prefix, so a user +session named HumanEventsAudit matched and got dropped on every run. + +Only the one-shot HumanEvents__ sessions belong here. +keeper_HumanEvents_ sessions are deliberately left out: they exist to +survive between runs when @keep_alive = 1, and the logging loop polls +them. Dropping those is only correct under @cleanup = 1, which the +cleanup label handles separately. +*/ IF @azure = 0 BEGIN INSERT @@ -580,7 +606,7 @@ BEGIN FROM sys.server_event_sessions AS ses LEFT JOIN sys.dm_xe_sessions AS dxe ON dxe.name = ses.name - WHERE ses.name LIKE N'HumanEvents%' + WHERE ses.name LIKE N'HumanEvents[_]%' AND (dxe.create_time < DATEADD(MINUTE, -1, SYSDATETIME()) OR dxe.create_time IS NULL); END; @@ -599,7 +625,7 @@ BEGIN FROM sys.database_event_sessions AS ses LEFT JOIN sys.dm_xe_database_sessions AS dxe ON dxe.name = ses.name - WHERE ses.name LIKE N'HumanEvents%' + WHERE ses.name LIKE N'HumanEvents[_]%' AND (dxe.create_time < DATEADD(MINUTE, -1, SYSDATETIME()) OR dxe.create_time IS NULL); END; @@ -1294,13 +1320,21 @@ IF @query_duration_ms > 0 BEGIN IF LOWER(@event_type) NOT LIKE N'%comp%' /* compile and recompile durations are tiny */ BEGIN - SET @query_duration_filter += N' AND duration >= ' + CONVERT(nvarchar(20), (@query_duration_ms * 1000)) + @nc10; + SET @query_duration_filter += N' AND duration >= ' + CONVERT(nvarchar(20), CONVERT(bigint, @query_duration_ms * 1000)) + @nc10; END; END; IF @blocking_duration_ms > 0 BEGIN - SET @blocking_duration_ms_filter += N' AND duration >= ' + CONVERT(nvarchar(20), (@blocking_duration_ms * 1000)) + @nc10; + /* + The bigint CONVERT must happen BEFORE the multiplication: + @blocking_duration_ms is an integer, and int * 1000 is evaluated in + int arithmetic, so anything over 2,147,483 ms (~36 minutes) + overflowed and killed the procedure with an arithmetic error. The + query duration filter above never had this problem — its parameter + is decimal(18,3), so its multiplication is decimal arithmetic. + */ + SET @blocking_duration_ms_filter += N' AND duration >= ' + CONVERT(nvarchar(20), CONVERT(bigint, @blocking_duration_ms) * 1000) + @nc10; END; IF @wait_duration_ms > 0 @@ -1722,12 +1756,35 @@ SET @session_sql += /* This creates the event session */ SET @session_sql += @session_with; -IF @debug = 1 BEGIN RAISERROR(@session_sql, 0, 1) WITH NOWAIT; END; -EXECUTE (@session_sql); +/* +A pure collector run — output database and schema set, @keep_alive = 0, +not cleaning up — GOTOs straight to the harvesting loop, which only +reads keeper_HumanEvents_ sessions. The throwaway GUID session this +would create is never read by that loop and never dropped by this +invocation (the teardown sits above the GOTO label), so it just ran +uselessly until a later invocation's startup cleanup reaped it. +@keep_alive = 1 with an output database still creates its session here: +that is the single-call create-and-collect path. +*/ +IF +( + @output_database_name <> N'' + AND @output_schema_name <> N'' + AND @cleanup = 0 + AND @keep_alive = 0 +) +BEGIN + IF @debug = 1 BEGIN RAISERROR(N'Collector run: skipping throwaway session creation', 0, 1) WITH NOWAIT; END; +END; +ELSE +BEGIN + IF @debug = 1 BEGIN RAISERROR(@session_sql, 0, 1) WITH NOWAIT; END; + EXECUTE (@session_sql); -/* This starts the event session */ -IF @debug = 1 BEGIN RAISERROR(@start_sql, 0, 1) WITH NOWAIT; END; -EXECUTE (@start_sql); + /* This starts the event session */ + IF @debug = 1 BEGIN RAISERROR(@start_sql, 0, 1) WITH NOWAIT; END; + EXECUTE (@start_sql); +END; /* bail out here if we want to keep the session and not log to tables*/ IF @keep_alive = 1 @@ -2009,6 +2066,13 @@ BEGIN q.query_plan_hash_signed, q.query_hash_signed, plan_handle = q.plan_handle, + /* + Only this branch holds real execution events; the showplan + branch below contributes memory numbers for the same hash + group. The executions count has to see the difference, or + collecting plans doubles it. + */ + is_execution = 1, /*totals*/ total_cpu_ms = ISNULL(q.cpu_ms, 0.), total_logical_reads = ISNULL(q.logical_reads, 0.), @@ -2038,6 +2102,7 @@ BEGIN q.query_plan_hash_signed, q.query_hash_signed, q.plan_handle, + is_execution = 0, /*totals*/ total_cpu_ms = NULL, total_logical_reads = NULL, @@ -2083,7 +2148,33 @@ BEGIN avg_used_memory_mb = AVG(qa.avg_used_memory_mb), avg_granted_memory_mb = AVG(qa.avg_granted_memory_mb), avg_rows = AVG(qa.avg_rows), - executions = COUNT_BIG(qa.plan_handle) + /* + Counts only rows from the execution branch, exactly as + COUNT_BIG(plan_handle) did before plan collection added the + showplan branch — which doubled this number whenever + @skip_plans = 0, because both branches carry a plan_handle. + Written as SUM over 1s and 0s so no NULL ever reaches the + aggregate. + + The CONVERT to bigint matches the shape the logged Queries + views use, SUM(CONVERT(bigint, ...)), and keeps the running + total off integer, which SUM over an integer literal would + overflow past 2.1 billion rows. + */ + executions = + SUM + ( + CONVERT + ( + bigint, + CASE + WHEN qa.is_execution = 1 + AND qa.plan_handle IS NOT NULL + THEN 1 + ELSE 0 + END + ) + ) INTO #totals FROM query_agg AS qa GROUP BY @@ -2250,6 +2341,105 @@ BEGIN ELSE q.total_cpu_ms END DESC OPTION(RECOMPILE); + + /* + Optional: surface the prepared-statement RPC plumbing that the query_hash_signed + filter above intentionally hides. sp_prepare, sp_prepexec, sp_execute, and sp_unprepare + all arrive with a zero query hash, so they never appear in the query results above. + Seeing them is the only reliable way to tell whether a client driver is preparing and + reusing statements. For example, pyodbc's fast_executemany switches an insert workload + from sp_prepexec-per-row (prepare and execute every time) to a single sp_prepare followed + by sp_execute-per-row (handle reuse); without this result set both look identical. + */ + IF @keep_prepare_rpc = 1 + BEGIN + WITH + prepare_rpc AS + ( + SELECT + event_time = + DATEADD + ( + MINUTE, + DATEDIFF + ( + MINUTE, + GETUTCDATE(), + SYSDATETIME() + ), + oa.c.value('@timestamp', 'datetime2') + ), + event_type = oa.c.value('@name', 'sysname'), + database_name = oa.c.value('(action[@name="database_name"]/value/text())[1]', 'sysname'), + object_name = oa.c.value('(data[@name="object_name"]/value/text())[1]', 'sysname'), + statement = oa.c.value('(data[@name="statement"]/value/text())[1]', 'nvarchar(max)'), + sql_text = oa.c.value('(action[@name="sql_text"]/value/text())[1]', 'nvarchar(max)'), + duration_ms = oa.c.value('(data[@name="duration"]/value/text())[1]', 'bigint') / 1000., + cpu_ms = oa.c.value('(data[@name="cpu_time"]/value/text())[1]', 'bigint') / 1000., + logical_reads_mb = (oa.c.value('(data[@name="logical_reads"]/value/text())[1]', 'bigint') * 8) / 1024., + physical_reads_mb = (oa.c.value('(data[@name="physical_reads"]/value/text())[1]', 'bigint') * 8) / 1024., + writes_mb = (oa.c.value('(data[@name="writes"]/value/text())[1]', 'bigint') * 8) / 1024., + row_count = oa.c.value('(data[@name="row_count"]/value/text())[1]', 'bigint') + FROM #human_events_xml AS xet + OUTER APPLY xet.human_events_xml.nodes('//event') AS oa(c) + WHERE oa.c.value('@name', 'sysname') = N'rpc_completed' + ) + SELECT + pr.event_time, + pr.event_type, + pr.database_name, + pr.object_name, + statement = + ( + SELECT + [processing-instruction(statement)] = + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + pr.statement COLLATE Latin1_General_BIN2, + NCHAR(31),N'?'),NCHAR(30),N'?'),NCHAR(29),N'?'),NCHAR(28),N'?'),NCHAR(27),N'?'),NCHAR(26),N'?'),NCHAR(25),N'?'),NCHAR(24),N'?'),NCHAR(23),N'?'),NCHAR(22),N'?'), + NCHAR(21),N'?'),NCHAR(20),N'?'),NCHAR(19),N'?'),NCHAR(18),N'?'),NCHAR(17),N'?'),NCHAR(16),N'?'),NCHAR(15),N'?'),NCHAR(14),N'?'),NCHAR(12),N'?'), + NCHAR(11),N'?'),NCHAR(8),N'?'),NCHAR(7),N'?'),NCHAR(6),N'?'),NCHAR(5),N'?'),NCHAR(4),N'?'),NCHAR(3),N'?'),NCHAR(2),N'?'),NCHAR(1),N'?'),NCHAR(0),N'?') + FOR XML + PATH(N''), + TYPE + ), + sql_text = + ( + SELECT + [processing-instruction(sql_text)] = + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + pr.sql_text COLLATE Latin1_General_BIN2, + NCHAR(31),N'?'),NCHAR(30),N'?'),NCHAR(29),N'?'),NCHAR(28),N'?'),NCHAR(27),N'?'),NCHAR(26),N'?'),NCHAR(25),N'?'),NCHAR(24),N'?'),NCHAR(23),N'?'),NCHAR(22),N'?'), + NCHAR(21),N'?'),NCHAR(20),N'?'),NCHAR(19),N'?'),NCHAR(18),N'?'),NCHAR(17),N'?'),NCHAR(16),N'?'),NCHAR(15),N'?'),NCHAR(14),N'?'),NCHAR(12),N'?'), + NCHAR(11),N'?'),NCHAR(8),N'?'),NCHAR(7),N'?'),NCHAR(6),N'?'),NCHAR(5),N'?'),NCHAR(4),N'?'),NCHAR(3),N'?'),NCHAR(2),N'?'),NCHAR(1),N'?'),NCHAR(0),N'?') + FOR XML + PATH(N''), + TYPE + ), + pr.duration_ms, + pr.cpu_ms, + pr.logical_reads_mb, + pr.physical_reads_mb, + pr.writes_mb, + pr.row_count + FROM prepare_rpc AS pr + WHERE + ( + pr.statement LIKE N'%sp[_]prepare%' + OR pr.statement LIKE N'%sp[_]prepexec%' + OR pr.statement LIKE N'%sp[_]execute%' + OR pr.statement LIKE N'%sp[_]unprepare%' + OR pr.statement LIKE N'%sp[_]cursor%' + OR pr.statement LIKE N'%sp[_]describe[_]undeclared[_]parameters%' + ) + AND pr.statement NOT LIKE N'%sp[_]executesql%' + ORDER BY + pr.event_time + OPTION(RECOMPILE); + END; END; @@ -2731,7 +2921,8 @@ BEGIN total_waits = COUNT_BIG(*), sum_duration_ms = SUM(wa.duration_ms), sum_signal_duration_ms = SUM(wa.signal_duration_ms), - avg_ms_per_wait = SUM(wa.duration_ms) / COUNT_BIG(*) + /* * 1.0 forces decimal division; bigint / bigint truncates */ + avg_ms_per_wait = CONVERT(decimal(38,2), SUM(wa.duration_ms) * 1.0 / COUNT_BIG(*)) FROM #waits_agg AS wa GROUP BY wa.wait_type @@ -2747,7 +2938,8 @@ BEGIN total_waits = COUNT_BIG(*), sum_duration_ms = SUM(wa.duration_ms), sum_signal_duration_ms = SUM(wa.signal_duration_ms), - avg_ms_per_wait = SUM(wa.duration_ms) / COUNT_BIG(*) + /* * 1.0 forces decimal division; bigint / bigint truncates */ + avg_ms_per_wait = CONVERT(decimal(38,2), SUM(wa.duration_ms) * 1.0 / COUNT_BIG(*)) FROM #waits_agg AS wa GROUP BY wa.database_name, @@ -2774,8 +2966,9 @@ BEGIN SUM(wa.duration_ms), sum_signal_duration_ms = SUM(wa.signal_duration_ms), + /* * 1.0 forces decimal division; bigint / bigint truncates */ avg_ms_per_wait = - SUM(wa.duration_ms) / COUNT_BIG(*) + CONVERT(decimal(38,2), SUM(wa.duration_ms) * 1.0 / COUNT_BIG(*)) FROM #waits_agg AS wa GROUP BY wa.database_name, @@ -3552,6 +3745,57 @@ BEGIN RAISERROR(N'and dropping session', 0, 1) WITH NOWAIT; END; EXECUTE (@drop_sql); + + /* + DROP EVENT SESSION removes only metadata — the event_file target's + .xel files stay on disk, and because every throwaway run uses a + fresh GUID in the filename, max_rollover_files never prunes across + runs: one orphaned file per run, forever. The file has served its + whole purpose by now (it was read into the temp tables above), so + delete it where we can. sys.xp_delete_files is checked by existence + rather than version on purpose — it is present on 2017 and 2019 as + well as 2022+ (verified live), just not documented until 2022 — and + it wants one absolute path pattern. Where it does not exist, say + where the file was left rather than leaving it silently. + */ + IF @azure = 0 + AND LOWER(@target_output) = N'event_file' + BEGIN + DECLARE + @xel_pattern nvarchar(4000) = + LEFT + ( + CONVERT(nvarchar(4000), SERVERPROPERTY('ErrorLogFileName')), + LEN(CONVERT(nvarchar(4000), SERVERPROPERTY('ErrorLogFileName'))) - + CHARINDEX(N'\', REVERSE(CONVERT(nvarchar(4000), SERVERPROPERTY('ErrorLogFileName')))) + ) + + N'\' + + @session_name + + N'*.xel'; + + IF EXISTS + ( + SELECT + 1/0 + FROM sys.all_objects AS ao + WHERE ao.name = N'xp_delete_files' + ) + BEGIN + BEGIN TRY + EXECUTE sys.xp_delete_files + @xel_pattern; + + IF @debug = 1 BEGIN RAISERROR(N'Deleted session files: %s', 0, 1, @xel_pattern) WITH NOWAIT; END; + END TRY + BEGIN CATCH + RAISERROR(N'Could not delete session files matching %s; clean them up manually.', 10, 1, @xel_pattern) WITH NOWAIT; + END CATCH; + END; + ELSE + BEGIN + RAISERROR(N'This SQL Server version cannot delete files from T-SQL; session files matching %s were left behind.', 10, 1, @xel_pattern) WITH NOWAIT; + END; + END; END; RETURN; @@ -3573,7 +3817,7 @@ BEGIN FROM sys.server_event_sessions AS ses LEFT JOIN sys.dm_xe_sessions AS dxs ON dxs.name = ses.name - WHERE ses.name LIKE N'keeper_HumanEvents_%' + WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%' AND dxs.create_time IS NOT NULL ) BEGIN @@ -3590,7 +3834,7 @@ BEGIN FROM sys.server_event_sessions AS ses LEFT JOIN sys.dm_xe_sessions AS dxs ON dxs.name = ses.name - WHERE ses.name LIKE N'keeper_HumanEvents_%' + WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%' AND dxs.create_time IS NULL; END; ELSE @@ -3603,7 +3847,7 @@ BEGIN FROM sys.database_event_sessions AS ses JOIN sys.dm_xe_database_sessions AS dxs ON dxs.name = ses.name - WHERE ses.name LIKE N'keeper_HumanEvents_%' + WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%' ) BEGIN IF @debug = 1 BEGIN RAISERROR(N'No matching active session names found starting with keeper_HumanEvents', 0, 1) WITH NOWAIT; END; @@ -3619,7 +3863,7 @@ BEGIN FROM sys.database_event_sessions AS ses LEFT JOIN sys.dm_xe_database_sessions AS dxs ON dxs.name = ses.name - WHERE ses.name LIKE N'keeper_HumanEvents_%' + WHERE ses.name LIKE N'keeper[_]HumanEvents[_]%' AND dxs.create_time IS NULL; END; @@ -3671,7 +3915,7 @@ BEGIN FROM sys.server_event_sessions AS s JOIN sys.dm_xe_sessions AS r ON r.name = s.name - WHERE s.name LIKE N'keeper_HumanEvents_%'; + WHERE s.name LIKE N'keeper[_]HumanEvents[_]%'; END; ELSE BEGIN @@ -3701,7 +3945,7 @@ BEGIN FROM sys.database_event_sessions AS s JOIN sys.dm_xe_database_sessions AS r ON r.name = s.name - WHERE s.name LIKE N'keeper_HumanEvents_%'; + WHERE s.name LIKE N'keeper[_]HumanEvents[_]%'; END; /*If we're getting compiles, and the parameterization event is available*/ @@ -3712,7 +3956,7 @@ BEGIN SELECT 1/0 FROM #human_events_worker AS hew - WHERE hew.event_type LIKE N'keeper_HumanEvents_compiles%' + WHERE hew.event_type LIKE N'keeper[_]HumanEvents[_]compiles%' ) BEGIN INSERT @@ -3740,7 +3984,7 @@ BEGIN hew.output_schema, hew.output_table + N'_parameterization' FROM #human_events_worker AS hew - WHERE hew.event_type LIKE N'keeper_HumanEvents_compiles%'; + WHERE hew.event_type LIKE N'keeper[_]HumanEvents[_]compiles%'; END; /*Update this column for when we see if we need to create views.*/ @@ -3749,16 +3993,35 @@ BEGIN SET hew.event_type_short = CASE - WHEN hew.event_type LIKE N'%block%' + /* + Every pattern here is anchored to the event-type + segment of the session name — + keeper_HumanEvents_[_] — + never floated over the whole string. A floating + match lets @custom_name contaminate the routing: a + waits session named with @custom_name = 'deadlock' + contains 'lock', and a floating %lock% classified + it as Blocking while the table-creation logic + (correctly) built it a waits table, so the Blocking + view was pointed at a waits table and the CREATE + VIEW error killed the entire harvest. + + Anchored, the aliases still all land: block, blocks, + blocking, lock, locks, and locking are covered by + the two blocking prefixes, and the compiles branch + needs no recompile exclusion because 'recomp...' + cannot start with 'comp'. + */ + WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]block%' + OR hew.event_type LIKE N'keeper[_]HumanEvents[_]lock%' THEN N'[_]Blocking' - WHEN ( hew.event_type LIKE N'%comp%' - AND hew.event_type NOT LIKE N'%re%' ) + WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]comp%' THEN N'[_]Compiles' - WHEN hew.event_type LIKE N'%quer%' + WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]quer%' THEN N'[_]Queries' - WHEN hew.event_type LIKE N'%recomp%' + WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]recomp%' THEN N'[_]Recompiles' - WHEN hew.event_type LIKE N'%wait%' + WHEN hew.event_type LIKE N'keeper[_]HumanEvents[_]wait%' THEN N'[_]Waits' ELSE N'?' END @@ -3799,7 +4062,19 @@ BEGIN N'.' + QUOTENAME(hew.output_schema) + N'.' + - QUOTENAME(hew.output_table) + QUOTENAME(hew.output_table), + /* + REPLACE on the RAW table name before quoting, so the + companion _parameterization worker row and the base + compiles row both resolve to the same, valid + [db].[schema].[base_parameterization] name. + */ + @param_table_check = + QUOTENAME(hew.output_database) + + N'.' + + QUOTENAME(hew.output_schema) + + N'.' + + QUOTENAME(REPLACE(hew.output_table, N'_parameterization', N'') + N'_parameterization') FROM #human_events_worker AS hew WHERE hew.id = @min_id AND hew.is_table_created = 0; @@ -3810,12 +4085,13 @@ BEGIN SELECT @table_sql = CASE - WHEN @event_type_check LIKE N'%wait%' + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]wait%' THEN N'CREATE TABLE ' + @object_name_check + @nc10 + N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 + N' database_name sysname NULL, wait_type nvarchar(60) NULL, duration_ms bigint NULL, signal_duration_ms bigint NULL, ' + @nc10 + N' wait_resource sysname NULL, query_plan_hash_signed binary(8) NULL, query_hash_signed binary(8) NULL, plan_handle varbinary(64) NULL );' - WHEN @event_type_check LIKE N'%lock%' + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]block%' + OR @event_type_check LIKE N'keeper[_]HumanEvents[_]lock%' THEN N'CREATE TABLE ' + @object_name_check + @nc10 + N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, ' + @nc10 + N' activity nvarchar(20) NULL, database_name sysname NULL, database_id integer NULL, object_id bigint NULL, contentious_object AS OBJECT_NAME(object_id, database_id), ' + @nc10 + @@ -3823,7 +4099,7 @@ BEGIN N' wait_time bigint NULL, transaction_name sysname NULL, last_transaction_started nvarchar(30) NULL, wait_resource nvarchar(100) NULL, ' + @nc10 + N' lock_mode nvarchar(10) NULL, status nvarchar(10) NULL, priority integer NULL, transaction_count integer NULL, ' + @nc10 + N' client_app sysname NULL, host_name sysname NULL, login_name sysname NULL, isolation_level nvarchar(30) NULL, sql_handle varbinary(64) NULL, blocked_process_report XML NULL );' - WHEN @event_type_check LIKE N'%quer%' + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]quer%' THEN N'CREATE TABLE ' + @object_name_check + @nc10 + N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 + N' database_name sysname NULL, object_name nvarchar(512) NULL, sql_text nvarchar(max) NULL, statement nvarchar(max) NULL, ' + @nc10 + @@ -3832,12 +4108,12 @@ BEGIN N' spills_mb decimal(18,2) NULL, row_count decimal(18,2) NULL, estimated_rows decimal(18,2) NULL, dop integer NULL, ' + @nc10 + N' serial_ideal_memory_mb decimal(18,2) NULL, requested_memory_mb decimal(18,2) NULL, used_memory_mb decimal(18,2) NULL, ideal_memory_mb decimal(18,2) NULL, ' + @nc10 + N' granted_memory_mb decimal(18,2) NULL, query_plan_hash_signed binary(8) NULL, query_hash_signed binary(8) NULL, plan_handle varbinary(64) NULL );' - WHEN @event_type_check LIKE N'%recomp%' + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]recomp%' THEN N'CREATE TABLE ' + @object_name_check + @nc10 + N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 + N' database_name sysname NULL, object_name nvarchar(512) NULL, recompile_cause sysname NULL, statement_text nvarchar(max) NULL, statement_text_checksum AS CHECKSUM(database_name + statement_text) PERSISTED ' + CASE WHEN @compile_events = 1 THEN N', compile_cpu_ms bigint NULL, compile_duration_ms bigint NULL );' ELSE N' );' END - WHEN @event_type_check LIKE N'%comp%' AND @event_type_check NOT LIKE N'%re%' + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]comp%' THEN N'CREATE TABLE ' + @object_name_check + @nc10 + N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 + N' database_name sysname NULL, object_name nvarchar(512) NULL, statement_text nvarchar(max) NULL, statement_text_checksum AS CHECKSUM(database_name + statement_text) PERSISTED ' @@ -3845,7 +4121,7 @@ BEGIN + CASE WHEN @parameterization_events = 1 THEN @nc10 + - N'CREATE TABLE ' + @object_name_check + N'_parameterization' + @nc10 + + N'CREATE TABLE ' + @param_table_check + @nc10 + N'( id bigint PRIMARY KEY IDENTITY, server_name sysname NULL, event_time datetime2 NULL, event_type sysname NULL, ' + @nc10 + N' database_name sysname NULL, sql_text nvarchar(max) NULL, compile_cpu_time_ms bigint NULL, compile_duration_ms bigint NULL, query_param_type integer NULL, ' + @nc10 + N' is_cached bit NULL, is_recompiled bit NULL, compile_code sysname NULL, has_literals bit NULL, is_parameterizable bit NULL, parameterized_values_count bigint NULL, ' + @nc10 + @@ -3856,9 +4132,21 @@ BEGIN END; END; - IF @debug = 1 BEGIN RAISERROR(@table_sql, 0, 1) WITH NOWAIT; END; - EXECUTE sys.sp_executesql - @table_sql; + /* + Guarded because @table_sql is only assigned inside the + IF OBJECT_ID(...) IS NULL block above. When a later worker + row's table already exists, the variable still holds the + PREVIOUS iteration's CREATE TABLE, and re-executing it threw + "There is already an object named..." and killed the loop. + */ + IF @table_sql <> N'' + BEGIN + IF @debug = 1 BEGIN RAISERROR(@table_sql, 0, 1) WITH NOWAIT; END; + EXECUTE sys.sp_executesql + @table_sql; + + SET @table_sql = N''; + END; IF @debug = 1 BEGIN RAISERROR(N'Updating #human_events_worker to set is_table_created for %s', 0, 1, @event_type_check) WITH NOWAIT; END; UPDATE @@ -3951,11 +4239,11 @@ BEGIN SELECT N'HumanEvents_RecompilesByQuery', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005200650063006F006D00700069006C006500730042007900510075006500720079000D000A00410053000D000A0057004900540048000D000A0020002000200020006300620071002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A0020002000200020002000200020002000730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D002C000D000A002000200020002000200020002000200074006F00740061006C005F007200650063006F006D00700069006C006500730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A002000200020002000200020002000200074006F00740061006C005F0063006F006D00700069006C0065005F0063007000750020003D002000530055004D00280063006F006D00700069006C0065005F006300700075005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F0063006F006D00700069006C0065005F0063007000750020003D002000410056004700280063006F006D00700069006C0065005F006300700075005F006D00730029002C000D000A00200020002000200020002000200020006D00610078005F0063006F006D00700069006C0065005F0063007000750020003D0020004D0041005800280063006F006D00700069006C0065005F006300700075005F006D00730029002C000D000A002000200020002000200020002000200074006F00740061006C005F0063006F006D00700069006C0065005F006400750072006100740069006F006E0020003D002000530055004D00280063006F006D00700069006C0065005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F0063006F006D00700069006C0065005F006400750072006100740069006F006E0020003D002000410056004700280063006F006D00700069006C0065005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006D00610078005F0063006F006D00700069006C0065005F006400750072006100740069006F006E0020003D0020004D0041005800280063006F006D00700069006C0065005F006400750072006100740069006F006E005F006D00730029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D000D000A002000200020002000470052004F005500500020004200590020000D000A0020002000200020002000200020002000730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D000D000A00200020002000200048004100560049004E00470020000D000A002000200020002000200020002000200043004F0055004E0054005F0042004900470028002A00290020003E003D002000310030000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A0020002000200020006B002E006F0062006A006500630074005F006E0061006D0065002C000D000A0020002000200020006B002E00730074006100740065006D0065006E0074005F0074006500780074002C000D000A00200020002000200063002E0074006F00740061006C005F007200650063006F006D00700069006C00650073002C000D000A00200020002000200063002E0074006F00740061006C005F0063006F006D00700069006C0065005F006300700075002C000D000A00200020002000200063002E006100760067005F0063006F006D00700069006C0065005F006300700075002C000D000A00200020002000200063002E006D00610078005F0063006F006D00700069006C0065005F006300700075002C000D000A00200020002000200063002E0074006F00740061006C005F0063006F006D00700069006C0065005F006400750072006100740069006F006E002C000D000A00200020002000200063002E006100760067005F0063006F006D00700069006C0065005F006400750072006100740069006F006E002C000D000A00200020002000200063002E006D00610078005F0063006F006D00700069006C0065005F006400750072006100740069006F006E000D000A00460052004F004D002000630062007100200041005300200063000D000A00430052004F005300530020004100500050004C0059000D000A0028000D000A002000200020002000530045004C00450043005400200054004F00500020002800310029000D000A00200020002000200020002000200020006B002E002A000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D0020004100530020006B000D000A00200020002000200057004800450052004500200063002E00730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D0020003D0020006B002E00730074006100740065006D0065006E0074005F0074006500780074005F0063006800650063006B00730075006D000D000A0020002000200020004F00520044004500520020004200590020006B002E0069006400200044004500530043000D000A00290020004100530020006B000D000A004F005200440045005200200042005900200063002E0074006F00740061006C005F007200650063006F006D00700069006C0065007300200044004500530043003B000D000A00 WHERE @compile_events = 1; INSERT #view_check (view_name, view_definition) - SELECT N'HumanEvents_WaitsByDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730042007900440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A002000200020002000770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730020006200790020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002F00200043004F0055004E0054005F0042004900470028002A0029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000770061006900740073005F007000650072005F007300650063006F006E00640020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028005300450043004F004E0044002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0068006F007500720020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C0049004600280044004100540045004400490046004600280048004F00550052002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0064006100790020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028004400410059002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029000D000A00460052004F004D002000770061006900740073000D000A00470052004F00550050002000420059000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074000D000A004F00520044004500520020004200590020000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00; + SELECT N'HumanEvents_WaitsByDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730042007900440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A002000200020002000770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730020006200790020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D00200043004F004E005600450052005400280064006500630069006D0061006C002800330038002C00320029002C002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002A00200031002E00300020002F00200043004F0055004E0054005F0042004900470028002A00290029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000770061006900740073005F007000650072005F007300650063006F006E00640020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028005300450043004F004E0044002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0068006F007500720020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C0049004600280044004100540045004400490046004600280048004F00550052002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029002C000D000A002000200020002000770061006900740073005F007000650072005F0064006100790020003D0020000D000A0020002000200020002000200020002000490053004E0055004C004C00280043004F0055004E0054005F0042004900470028002A00290020002F0020004E0055004C004C004900460028004400410054004500440049004600460028004400410059002C002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D00650029002C002000300029002C002000300029000D000A00460052004F004D002000770061006900740073000D000A00470052004F00550050002000420059000D000A002000200020002000770061006900740073002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000770061006900740073002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000770061006900740073002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000770061006900740073002E0077006100690074005F0074007900700065002C000D000A002000200020002000770061006900740073002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000770061006900740073002E006100760067005F006D0073005F007000650072005F0077006100690074000D000A004F00520044004500520020004200590020000D000A002000200020002000770061006900740073002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00; INSERT #view_check (view_name, view_definition) - SELECT N'HumanEvents_WaitsByQueryAndDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F0057006100690074007300420079005100750065007200790041006E006400440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A00200020002000200070006C0061006E005F00770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270077006100690074007300200062007900200071007500650072007900200061006E00640020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002F00200043004F0055004E0054005F0042004900470028002A0029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000700077002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000700077002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000700077002E0077006100690074005F0074007900700065002C000D000A002000200020002000700077002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000730074002E0074006500780074002C000D000A002000200020002000710070002E00710075006500720079005F0070006C0061006E000D000A00460052004F004D00200070006C0061006E005F00770061006900740073002000410053002000700077000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00710075006500720079005F0070006C0061006E002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000710070000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00730071006C005F0074006500780074002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000730074000D000A004F00520044004500520020004200590020000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00; + SELECT N'HumanEvents_WaitsByQueryAndDatabase', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F0057006100690074007300420079005100750065007200790041006E006400440061007400610062006100730065000D000A00410053000D000A0057004900540048000D000A00200020002000200070006C0061006E005F00770061006900740073002000410053000D000A0028000D000A002000200020002000530045004C004500430054000D000A002000200020002000200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270077006100690074007300200062007900200071007500650072007900200061006E00640020006400610074006100620061007300650027002C000D000A00200020002000200020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A00200020002000200020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A002000200020002000200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A00200020002000200020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D00200043004F004E005600450052005400280064006500630069006D0061006C002800330038002C00320029002C002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002A00200031002E00300020002F00200043004F0055004E0054005F0042004900470028002A00290029000D000A002000200020002000460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A002000200020002000470052004F00550050002000420059000D000A0020002000200020002000200020002000770061002E00640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E00710075006500720079005F0070006C0061006E005F0068006100730068005F007300690067006E00650064002C000D000A0020002000200020002000200020002000770061002E0070006C0061006E005F00680061006E0064006C0065000D000A0029000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A002000200020002000700077002E0077006100690074005F007000610074007400650072006E002C000D000A002000200020002000700077002E006D0069006E005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E006D00610078005F006500760065006E0074005F00740069006D0065002C000D000A002000200020002000700077002E00640061007400610062006100730065005F006E0061006D0065002C000D000A002000200020002000700077002E0077006100690074005F0074007900700065002C000D000A002000200020002000700077002E0074006F00740061006C005F00770061006900740073002C000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E00730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D0073002C000D000A002000200020002000700077002E006100760067005F006D0073005F007000650072005F0077006100690074002C000D000A002000200020002000730074002E0074006500780074002C000D000A002000200020002000710070002E00710075006500720079005F0070006C0061006E000D000A00460052004F004D00200070006C0061006E005F00770061006900740073002000410053002000700077000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00710075006500720079005F0070006C0061006E002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000710070000D000A004F00550054004500520020004100500050004C00590020007300790073002E0064006D005F0065007800650063005F00730071006C005F0074006500780074002800700077002E0070006C0061006E005F00680061006E0064006C00650029002000410053002000730074000D000A004F00520044004500520020004200590020000D000A002000200020002000700077002E00730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00; INSERT #view_check (view_name, view_definition) - SELECT N'HumanEvents_WaitsTotal', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730054006F00740061006C000D000A00410053000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A00200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730027002C000D000A0020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A00200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002F00200043004F0055004E0054005F0042004900470028002A0029000D000A00460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A00470052004F005500500020004200590020000D000A002000200020002000770061002E0077006100690074005F0074007900700065000D000A004F00520044004500520020004200590020000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00; + SELECT N'HumanEvents_WaitsTotal', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F005700610069007400730054006F00740061006C000D000A00410053000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A00200020002000200077006100690074005F007000610074007400650072006E0020003D0020004E00270074006F00740061006C0020007700610069007400730027002C000D000A0020002000200020006D0069006E005F006500760065006E0074005F00740069006D00650020003D0020004D0049004E002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A0020002000200020006D00610078005F006500760065006E0074005F00740069006D00650020003D0020004D00410058002800770061002E006500760065006E0074005F00740069006D00650029002C000D000A002000200020002000770061002E0077006100690074005F0074007900700065002C000D000A00200020002000200074006F00740061006C005F007700610069007400730020003D00200043004F0055004E0054005F0042004900470028002A0029002C000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E006400750072006100740069006F006E005F006D00730029002C000D000A002000200020002000730075006D005F007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730020003D002000530055004D002800770061002E007300690067006E0061006C005F006400750072006100740069006F006E005F006D00730029002C000D000A0020002000200020006100760067005F006D0073005F007000650072005F00770061006900740020003D00200043004F004E005600450052005400280064006500630069006D0061006C002800330038002C00320029002C002000530055004D002800770061002E006400750072006100740069006F006E005F006D007300290020002A00200031002E00300020002F00200043004F0055004E0054005F0042004900470028002A00290029000D000A00460052004F004D0020005B007200650070006C006100630065005F006D0065005D002000410053002000770061000D000A00470052004F005500500020004200590020000D000A002000200020002000770061002E0077006100690074005F0074007900700065000D000A004F00520044004500520020004200590020000D000A002000200020002000730075006D005F006400750072006100740069006F006E005F006D007300200044004500530043003B00; INSERT #view_check (view_name, view_definition) SELECT N'HumanEvents_Compiles_Legacy', 0x430052004500410054004500200056004900450057002000640062006F002E00480075006D0061006E004500760065006E00740073005F0043006F006D00700069006C00650073005F004C00650067006100630079000D000A00410053000D000A00530045004C00450043005400200054004F00500020002800320031003400370034003800330036003400380029000D000A0020002000200020006500760065006E0074005F00740069006D0065002C000D000A0020002000200020006500760065006E0074005F0074007900700065002C000D000A002000200020002000640061007400610062006100730065005F006E0061006D0065002C000D000A0020002000200020006F0062006A006500630074005F006E0061006D0065002C000D000A002000200020002000730074006100740065006D0065006E0074005F0074006500780074000D000A00460052004F004D0020005B007200650070006C006100630065005F006D0065005D000D000A004F00520044004500520020004200590020000D000A0020002000200020006500760065006E0074005F00740069006D0065003B00 WHERE @compile_events = 0; @@ -3981,6 +4269,14 @@ BEGIN AND hew.is_table_created = 1 AND hew.is_view_created = 0; + /* + The compiles pattern matches two rows: the base compiles row and + the _parameterization row derived from it, whose output_table is + the base name plus that suffix. Without the closing predicate the + join picks either one non-deterministically, and the row it picks + decides whether the view points at the right table. Only the base + row is a valid source here. + */ UPDATE vc SET @@ -3988,7 +4284,8 @@ BEGIN FROM #view_check AS vc JOIN #human_events_worker AS hew ON vc.view_name = N'HumanEvents_Parameterization' - AND hew.output_table LIKE N'keeper_HumanEvents_compiles%' + AND hew.output_table LIKE N'keeper[_]HumanEvents[_]compiles%' + AND hew.output_table NOT LIKE N'%[_]parameterization' AND hew.is_table_created = 1 AND hew.is_view_created = 0; @@ -4162,6 +4459,13 @@ END; QUOTENAME(hew.output_schema) + N'.' + QUOTENAME(hew.output_table), + /* Same construction as the create-table loop above. */ + @param_table_check = + QUOTENAME(hew.output_database) + + N'.' + + QUOTENAME(hew.output_schema) + + N'.' + + QUOTENAME(REPLACE(hew.output_table, N'_parameterization', N'') + N'_parameterization'), @date_filter = DATEADD ( @@ -4186,7 +4490,7 @@ END; ( nvarchar(max), CASE - WHEN @event_type_check LIKE N'%wait%' /*Wait stats!*/ + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]wait%' /*Wait stats!*/ THEN CONVERT ( nvarchar(max), @@ -4242,7 +4546,8 @@ WHERE (c.exist(''(data[@name="duration"]/value/text()[. > 0])'') = 1 OR @gimme_danger = 1) AND c.exist(''@timestamp[. > sql:variable("@date_filter")]'') = 1;') ) - WHEN @event_type_check LIKE N'%lock%' /*Blocking!*/ + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]block%' + OR @event_type_check LIKE N'keeper[_]HumanEvents[_]lock%' /*Blocking!*/ /*To cut down on nonsense, I'm only inserting new blocking scenarios*/ /*Any existing blocking scenarios will update the blocking duration*/ THEN CONVERT @@ -4422,7 +4727,7 @@ JOIN AND x.hostname = x2.host_name AND x.loginname = x2.login_name; ' )) - WHEN @event_type_check LIKE N'%quer%' /*Queries!*/ + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]quer%' /*Queries!*/ THEN CONVERT ( @@ -4485,7 +4790,7 @@ OUTER APPLY xet.human_events_xml.nodes(''//event'') AS oa(c) WHERE oa.c.exist(''@timestamp[. > sql:variable("@date_filter")]'') = 1 AND oa.c.exist(''(action[@name="query_hash_signed"]/value[. != 0])'') = 1; ' )) - WHEN @event_type_check LIKE N'%recomp%' /*Recompiles!*/ + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]recomp%' /*Recompiles!*/ THEN CONVERT ( @@ -4532,7 +4837,7 @@ AND oa.c.exist(''@timestamp[. > sql:variable("@date_filter")]'') = 1 ORDER BY event_time;' ))) - WHEN @event_type_check LIKE N'%comp%' AND @event_type_check NOT LIKE N'%re%' /*Compiles!*/ + WHEN @event_type_check LIKE N'keeper[_]HumanEvents[_]comp%' /*Compiles!*/ THEN CONVERT ( @@ -4586,7 +4891,7 @@ ORDER BY CONVERT ( nvarchar(max), - N'INSERT INTO ' + REPLACE(@object_name_check, N'_parameterization', N'') + N'_parameterization' + N' WITH(TABLOCK) ' + @nc10 + + N'INSERT INTO ' + @param_table_check + N' WITH(TABLOCK) ' + @nc10 + N'( server_name, event_time, event_type, database_name, sql_text, compile_cpu_time_ms, ' + @nc10 + N' compile_duration_ms, query_param_type, is_cached, is_recompiled, compile_code, has_literals, ' + @nc10 + N' is_parameterizable, parameterized_values_count, query_plan_hash, query_hash, plan_handle, statement_sql_hash ) ' + @nc10 + @@ -4928,23 +5233,47 @@ BEGIN /*Clean up tables*/ IF @debug = 1 BEGIN RAISERROR(N'CLEAN UP PARTY TONIGHT', 0, 1) WITH NOWAIT; END; + /* + Three deliberate things here, all learned the hard way: + + The name pattern is anchored and underscore-escaped. The old + unanchored %HumanEvents% happily dropped any user table whose name + merely contained the string, in every schema of the output database. + The tool's own tables are all named keeper_HumanEvents_..., so that + is all this may match. + + The schema is resolved by joining the OUTPUT database's sys.schemas, + not with SCHEMA_NAME(), which runs in the calling database's context + — for a cross-database schema_id it returns NULL, and one NULL turns + the whole += accumulation NULL, silently cleaning up nothing. + + And it only touches @output_schema_name, where the tool created its + objects in the first place. + */ + SET @drop_holder = N''; + SELECT @cleanup_tables += N' SELECT @i_cleanup_tables += N''DROP TABLE '' + - QUOTENAME(SCHEMA_NAME(s.schema_id)) + + QUOTENAME(sch.name) + N''.'' + QUOTENAME(s.name) + ''; '' + NCHAR(10) FROM ' + QUOTENAME(@output_database_name) + N'.sys.tables AS s - WHERE s.name LIKE ''' + '%HumanEvents%' + N''';'; + JOIN ' + QUOTENAME(@output_database_name) + N'.sys.schemas AS sch + ON sch.schema_id = s.schema_id + WHERE s.name LIKE N''keeper[_]HumanEvents[_]%'' + AND sch.name = @sch;'; EXECUTE sys.sp_executesql @cleanup_tables, - N'@i_cleanup_tables nvarchar(max) OUTPUT', - @i_cleanup_tables = @drop_holder OUTPUT; + N'@i_cleanup_tables nvarchar(max) OUTPUT, + @sch sysname', + @i_cleanup_tables = @drop_holder OUTPUT, + @sch = @output_schema_name; IF @debug = 1 BEGIN @@ -4959,23 +5288,33 @@ BEGIN SET @drop_holder = N''; + /* + Same anchoring, schema join, and schema scope as the table cleanup. + The tool's views are named HumanEvents_... (no keeper prefix), so + the anchor differs from the table pattern on purpose. + */ SELECT @cleanup_views += N' SELECT @i_cleanup_views += N''DROP VIEW '' + - QUOTENAME(SCHEMA_NAME(v.schema_id)) + + QUOTENAME(sch.name) + N''.'' + QUOTENAME(v.name) + ''; '' + NCHAR(10) FROM ' + QUOTENAME(@output_database_name) + N'.sys.views AS v - WHERE v.name LIKE ''' + '%HumanEvents%' + N''';'; + JOIN ' + QUOTENAME(@output_database_name) + N'.sys.schemas AS sch + ON sch.schema_id = v.schema_id + WHERE v.name LIKE N''HumanEvents[_]%'' + AND sch.name = @sch;'; EXECUTE sys.sp_executesql @cleanup_views, - N'@i_cleanup_views nvarchar(max) OUTPUT', - @i_cleanup_views = @drop_holder OUTPUT; + N'@i_cleanup_views nvarchar(max) OUTPUT, + @sch sysname', + @i_cleanup_views = @drop_holder OUTPUT, + @sch = @output_schema_name; IF @debug = 1 BEGIN @@ -4992,9 +5331,12 @@ END TRY /*Very professional error handling*/ BEGIN CATCH BEGIN - IF @@TRANCOUNT > 0 - ROLLBACK TRANSACTION; - + /* + No ROLLBACK. This procedure manages Extended Events sessions via DDL, + which autocommits, and opens no transaction of its own - so a ROLLBACK + here could only unwind the CALLER's transaction on an internal error. + The session cleanup below is the error handling that actually matters. + */ /*Only try to drop a session if we're not outputting*/ IF (@output_database_name = N'' AND @output_schema_name IN (N'', N'dbo')) @@ -5012,6 +5354,11 @@ BEGIN CATCH session alone so the original error still surfaces via THROW. Azure SQL DB uses database-scoped sessions, so the catalog view differs; that's the only reason for the @azure branch. + + The stop and the drop get the same treatment for the same + reason. Both are cleanup running inside the CATCH, so a + failure in either one is noise, and letting it escape would + replace the original error before THROW ever runs. */ BEGIN TRY IF @azure = 0 @@ -5058,14 +5405,26 @@ BEGIN CATCH RAISERROR(@stop_sql, 0, 1) WITH NOWAIT; RAISERROR(N'all done, stopping session', 0, 1) WITH NOWAIT; END; - EXECUTE (@stop_sql); + + BEGIN TRY + EXECUTE (@stop_sql); + END TRY + BEGIN CATCH + RAISERROR(N'Could not stop the event session while cleaning up. The original error follows.', 0, 1) WITH NOWAIT; + END CATCH; IF @debug = 1 BEGIN RAISERROR(@drop_sql, 0, 1) WITH NOWAIT; RAISERROR(N'and dropping session', 0, 1) WITH NOWAIT; END; - EXECUTE (@drop_sql); + + BEGIN TRY + EXECUTE (@drop_sql); + END TRY + BEGIN CATCH + RAISERROR(N'Could not drop the event session while cleaning up. The original error follows.', 0, 1) WITH NOWAIT; + END CATCH; END; END; diff --git a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql index 19ed3e0b..827c69e7 100644 --- a/sp_HumanEvents/sp_HumanEventsBlockViewer.sql +++ b/sp_HumanEvents/sp_HumanEventsBlockViewer.sql @@ -94,8 +94,8 @@ SET XACT_ABORT OFF; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - @version = '5.7', - @version_date = '20260701'; + @version = '5.8', + @version_date = '20260801'; IF @help = 1 BEGIN @@ -107,7 +107,7 @@ BEGIN SELECT 'it will also work with any other extended event session that captures blocking' UNION ALL SELECT 'just use the @session_name parameter to point me there' UNION ALL SELECT 'EXECUTE dbo.sp_HumanEventsBlockViewer @session_name = N''blocked_process_report'';' UNION ALL - SELECT 'the system_health session also works, if you are okay with its lousy blocked process report' + SELECT 'the system_health session also works, if you are okay with its lousy blocked process report' UNION ALL SELECT 'all scripts and documentation are available here: https://code.erikdarling.com' UNION ALL SELECT 'from your loving sql server consultant, erik darling: https://erikdarling.com'; @@ -285,6 +285,16 @@ IF EXISTS AND CONVERT(integer, c.value_in_use) = 0 ) AND @session_name NOT LIKE N'system%health' +/* +Table-source mode reads blocked process report XML somebody already +captured into a table, so the server's CURRENT threshold setting has +no bearing on it. The exemption checks @target_table AND @target_column +because table-mode routing guarantees both by the time any work +happens: supplying either one auto-promotes @target_type to table mode +further down, and a call with only one of the two routes to table mode +and fails its parameter validation before any reading starts. +*/ +AND (@target_table IS NULL OR @target_column IS NULL) BEGIN RAISERROR(N'Unless you want to use the lousy version in system_health, the blocked process report needs to be enabled: EXECUTE sys.sp_configure ''show advanced options'', 1; @@ -304,6 +314,8 @@ IF EXISTS AND CONVERT(integer, c.value_in_use) <> 5 ) AND @session_name NOT LIKE N'system%health' +/* Same table-mode exemption as the hard gate above. */ +AND (@target_table IS NULL OR @target_column IS NULL) BEGIN RAISERROR(N'For best results, set up the blocked process report like this: EXECUTE sys.sp_configure ''show advanced options'', 1; @@ -340,8 +352,12 @@ DECLARE ), @resolve_sql nvarchar(max), @resolve_database_id integer, + @resolve_file_id integer, + @resolve_page_id bigint, + @resolve_object_id integer, @key_resolution_dbs CURSOR, @page_resolution_dbs CURSOR, + @page_resolution_pages CURSOR, @azure_msg nchar(1), @session_id integer, @target_session_id integer, @@ -353,13 +369,27 @@ DECLARE @start_date_original datetime2 = @start_date, @end_date_original datetime2 = @end_date, @validation_sql nvarchar(max), + /* + Set by the table-source validation batch. A RETURN inside dynamic + SQL exits only that batch — the caller keeps running — so the batch + reports failure through this OUTPUT flag and the procedure checks + it right after the EXECUTE. A return code will not work here: + RETURN is not allowed in an sp_executesql batch. + */ + @validation_failed bit = 0, @extract_sql nvarchar(max), - /*Log to table stuff*/ - @log_table_blocking sysname, + /* + Log to table stuff. The table-name holder carries a fully-qualified, + QUOTENAME'd three-part name — up to 258 characters per part plus two + dots — so sysname silently truncated long-but-legal names and the + CREATE TABLE failed on the mangled remainder. + */ + @log_table_blocking nvarchar(776), @cleanup_date datetime2(7), @check_sql nvarchar(max) = N'', @create_sql nvarchar(max) = N'', @insert_sql nvarchar(max) = N'', + @mdsql_execute nvarchar(max) = N'', @log_database_schema nvarchar(1024), @max_event_time datetime2(7), @dsql nvarchar(max) = N'', @@ -441,30 +471,31 @@ SELECT THEN 1 ELSE 0 END, + /* + {table_check_literal} carries the same name as {table_check}, but + lands inside a quoted string where an apostrophe in a legal + identifier is fatal unless doubled — so the literal position gets + the quote-doubled value at the substitution site and the FROM keeps + the raw bracketed name. + + The @fallback_date parameter covers the first (empty-table) logging + run, and two things about it were wrong before: it hardcoded one + day back, silently discarding older events inside the caller's + requested window, and it was UTC-shifted while MAX(event_time) — + the value it stands in for — is stored in server-local time, which + pushed the watermark into the future for west-of-UTC servers and + dropped that run's events entirely. The substitution site passes + the caller's local-domain start date instead. + */ @mdsql = N' -IF OBJECT_ID(''{table_check}'', ''U'') IS NOT NULL +IF OBJECT_ID(''{table_check_literal}'', ''U'') IS NOT NULL BEGIN SELECT @max_event_time = ISNULL ( MAX({date_column}), - DATEADD - ( - MINUTE, - DATEDIFF - ( - MINUTE, - SYSDATETIME(), - GETUTCDATE() - ), - DATEADD - ( - DAY, - -1, - SYSDATETIME() - ) - ) + @fallback_date ) FROM {table_check}; END;'; @@ -581,6 +612,7 @@ BEGIN ) BEGIN RAISERROR(N''The specified @target_schema %s does not exist in @database %s'', 11, 1, @schema, @database) WITH NOWAIT; + SET @validation_failed = 1; RETURN; END; @@ -597,6 +629,7 @@ BEGIN ) BEGIN RAISERROR(N''The specified @target_table %s does not exist in @schema %s in database %s'', 11, 1, @table, @schema, @database) WITH NOWAIT; + SET @validation_failed = 1; RETURN; END; @@ -616,6 +649,7 @@ BEGIN ) BEGIN RAISERROR(N''The specified @target_column %s does not exist in table %s.%s in database %s'', 11, 1, @column, @schema, @table, @database) WITH NOWAIT; + SET @validation_failed = 1; RETURN; END; @@ -638,6 +672,7 @@ BEGIN ) BEGIN RAISERROR(N''The specified @target_column %s must be of XML data type.'', 11, 1, @column) WITH NOWAIT; + SET @validation_failed = 1; RETURN; END; '; @@ -661,6 +696,7 @@ BEGIN ) BEGIN RAISERROR(N''The specified @timestamp_column %s does not exist in table %s.%s in database %s'', 11, 1, @timestamp_column, @schema, @table, @database) WITH NOWAIT; + SET @validation_failed = 1; RETURN; END; @@ -683,6 +719,7 @@ BEGIN ) BEGIN RAISERROR(N''The specified @timestamp_column %s must be of datetime data type.'', 11, 1, @timestamp_column) WITH NOWAIT; + SET @validation_failed = 1; RETURN; END;'; END; @@ -699,13 +736,20 @@ BEGIN @schema sysname, @table sysname, @column sysname, - @timestamp_column sysname + @timestamp_column sysname, + @validation_failed bit OUTPUT ', @target_database, @target_schema, @target_table, @target_column, - @timestamp_column; + @timestamp_column, + @validation_failed OUTPUT; + + IF @validation_failed = 1 + BEGIN + RETURN; + END; END; /* Validate logging parameters */ @@ -723,6 +767,20 @@ BEGIN ELSE @log_retention_days END; + /* + QUOTENAME returns NULL for any input over 128 characters, and a NULL + table name turns every logging statement below into a silent no-op: + no table, no rows, no error. The one suffix appended to the prefix, + _BlockedProcessReport, is 21 characters, so the prefix gets 107. + */ + /* DATALENGTH, not LEN: LEN ignores trailing spaces, which still + count toward the 128-character identifier limit inside QUOTENAME. */ + IF DATALENGTH(@log_table_name_prefix) > 214 + BEGIN + RAISERROR(N'@log_table_name_prefix is limited to 107 characters, so the table name suffix (_BlockedProcessReport) still fits in an identifier. Shorten it and run me again.', 11, 1) WITH NOWAIT; + RETURN; + END; + /* Validate database exists */ IF NOT EXISTS ( @@ -732,7 +790,7 @@ BEGIN WHERE d.name = @log_database_name ) BEGIN - RAISERROR('The specified logging database %s does not exist. Logging will be disabled.', 11, 1, @log_database_name) WITH NOWAIT; + RAISERROR(N'The specified logging database %s does not exist. Fix @log_database_name and run me again.', 11, 1, @log_database_name) WITH NOWAIT; RETURN; END; @@ -822,7 +880,13 @@ BEGIN blocked_process_report_xml xml NULL PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for significant waits logging.'', 0, 1, ''' + @log_table_blocking + N''') WITH NOWAIT; END; + /* + The REPLACE doubles any single quote in the bracketed name: + this embed sits inside a string literal, where an apostrophe + in a legal identifier otherwise ends the literal early and + the whole batch fails to compile. + */ + IF @debug = 1 BEGIN RAISERROR(''Created table %s for blocked process report logging.'', 0, 1, ''' + REPLACE(@log_table_blocking, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1874,21 +1938,35 @@ BEGIN RAISERROR('Extracting blocked process reports from table %s.%s.%s', 0, 1, @target_database, @target_schema, @target_table) WITH NOWAIT; END; - /* Build dynamic SQL to extract the XML */ + /* + Build dynamic SQL to extract the XML. + + The ORDER BY event_timestamp DESC under the TOP is what makes + @max_blocking_events keep the MOST RECENT events, matching the + ring buffer and event file paths and the summary text. Without it, + TOP took whatever scan order produced — for an append-only log + table, the OLDEST rows — and silently hid the newest events, which + are the ones an investigation is usually after. + */ SET @extract_sql = N' - SELECT TOP (' + CONVERT(nvarchar(20), CASE WHEN @max_blocking_events > 0 THEN @max_blocking_events ELSE 2147483647 END) + N') - human_events_xml = e.x.query(''.'') - FROM ' + - QUOTENAME(@target_database) + - N'.' + - QUOTENAME(@target_schema) + - N'.' + - QUOTENAME(@target_table) + - N' AS x - CROSS APPLY x.' + - QUOTENAME(@target_column) + - N'.nodes(''/event'') AS e(x) - WHERE e.x.exist(''@name[ .= "blocked_process_report"]'') = 1'; + SELECT + me.human_events_xml + FROM + ( + SELECT TOP (' + CONVERT(nvarchar(20), CASE WHEN @max_blocking_events > 0 THEN @max_blocking_events ELSE 2147483647 END) + N') + human_events_xml = e.x.query(''.''), + event_timestamp = e.x.value(''@timestamp'', ''datetime2'') + FROM ' + + QUOTENAME(@target_database) + + N'.' + + QUOTENAME(@target_schema) + + N'.' + + QUOTENAME(@target_table) + + N' AS x + CROSS APPLY x.' + + QUOTENAME(@target_column) + + N'.nodes(''/event'') AS e(x) + WHERE e.x.exist(''@name[ .= "blocked_process_report"]'') = 1'; /* Add timestamp filtering if specified. @@ -1903,17 +1981,20 @@ BEGIN IF @timestamp_column IS NOT NULL BEGIN SET @extract_sql = @extract_sql + N' - AND x.' + QUOTENAME(@timestamp_column) + N' >= @start_date - AND x.' + QUOTENAME(@timestamp_column) + N' < @end_date'; + AND x.' + QUOTENAME(@timestamp_column) + N' >= @start_date + AND x.' + QUOTENAME(@timestamp_column) + N' < @end_date'; END; IF @timestamp_column IS NULL BEGIN SET @extract_sql = @extract_sql + N' - AND e.x.exist(''@timestamp[. >= sql:variable("@start_date") and . < sql:variable("@end_date")]'') = 1'; + AND e.x.exist(''@timestamp[. >= sql:variable("@start_date") and . < sql:variable("@end_date")]'') = 1'; END; SET @extract_sql = @extract_sql + N' + ORDER BY + event_timestamp DESC + ) AS me OPTION(RECOMPILE); '; @@ -1967,7 +2048,12 @@ SELECT object_id = c.value('(data[@name="object_id"]/value/text())[1]', 'integer'), transaction_id = c.value('(data[@name="transaction_id"]/value/text())[1]', 'bigint'), resource_owner_type = c.value('(data[@name="resource_owner_type"]/text)[1]', 'nvarchar(256)'), - monitor_loop = c.value('(//@monitorLoop)[1]', 'integer'), + /* ISNULL: a BPR with no monitorLoop attribute otherwise NULLs this, + which fails the NOT NULL #session_leads inserts and turns every + monitor_loop equality UNKNOWN, silently dropping the Top Lead + Blocker rollup. Real loop counters are large positive numbers, + so 0 cannot collide. */ + monitor_loop = ISNULL(c.value('(//@monitorLoop)[1]', 'integer'), 0), blocking_spid = bg.value('(process/@spid)[1]', 'integer'), blocking_ecid = bg.value('(process/@ecid)[1]', 'integer'), blocked_spid = bd.value('(process/@spid)[1]', 'integer'), @@ -2087,7 +2173,12 @@ SELECT object_id = c.value('(data[@name="object_id"]/value/text())[1]', 'integer'), transaction_id = c.value('(data[@name="transaction_id"]/value/text())[1]', 'bigint'), resource_owner_type = c.value('(data[@name="resource_owner_type"]/text)[1]', 'nvarchar(256)'), - monitor_loop = c.value('(//@monitorLoop)[1]', 'integer'), + /* ISNULL: a BPR with no monitorLoop attribute otherwise NULLs this, + which fails the NOT NULL #session_leads inserts and turns every + monitor_loop equality UNKNOWN, silently dropping the Top Lead + Blocker rollup. Real loop counters are large positive numbers, + so 0 cannot collide. */ + monitor_loop = ISNULL(c.value('(//@monitorLoop)[1]', 'integer'), 0), blocking_spid = bg.value('(process/@spid)[1]', 'integer'), blocking_ecid = bg.value('(process/@ecid)[1]', 'integer'), blocked_spid = bd.value('(process/@spid)[1]', 'integer'), @@ -2388,7 +2479,11 @@ SELECT kheb.currentdbname, kheb.currentdbid, kheb.blocked_process_report, - kheb.sort_order + kheb.sort_order, + /* chain identity keys - the contentious-object inheritance UPDATE self-joins #blocks on these */ + kheb.monitor_loop, + kheb.blocking_desc, + kheb.blocked_desc INTO #blocks FROM ( @@ -2667,26 +2762,69 @@ BEGIN IF DB_NAME(@resolve_database_id) IS NOT NULL AND DATABASEPROPERTYEX(DB_NAME(@resolve_database_id), 'Status') = N'ONLINE' BEGIN - SET @resolve_sql = N' - BEGIN TRY - UPDATE - b - SET - b.resource_object_id = pi.object_id + /* + One DMV call per distinct page, each in its own TRY/CATCH. + dm_db_page_info RAISES for a deallocated or out-of-range + page rather than returning NULL, so the previous set-based + CROSS APPLY died on the first bad page and its CATCH + swallowed the whole database's resolution — one stale page + left every other page in that database unresolved. Per-page, + a bad page loses only itself. + */ + SET @page_resolution_pages = + CURSOR + LOCAL FAST_FORWARD + FOR + SELECT DISTINCT + b.resource_file_id, + b.resource_page_id FROM #blocks AS b - CROSS APPLY sys.dm_db_page_info(b.resource_database_id, b.resource_file_id, b.resource_page_id, ''LIMITED'') AS pi - WHERE b.lock_type IN (''PAGE'', ''RID'') + WHERE b.lock_type IN ('PAGE', 'RID') AND b.resource_database_id = @resolve_database_id - OPTION(RECOMPILE); - END TRY - BEGIN CATCH - /* no VIEW DATABASE STATE / page reallocated -> leave for labeling */ - END CATCH;'; - - EXECUTE sys.sp_executesql - @resolve_sql, - N'@resolve_database_id integer', - @resolve_database_id; + AND b.resource_file_id IS NOT NULL + AND b.resource_page_id IS NOT NULL; + + OPEN @page_resolution_pages; + FETCH NEXT FROM @page_resolution_pages INTO @resolve_file_id, @resolve_page_id; + WHILE @@FETCH_STATUS = 0 + BEGIN + SET @resolve_object_id = NULL; + SET @resolve_sql = N' + BEGIN TRY + SELECT + @resolve_object_id = pi.object_id + FROM sys.dm_db_page_info(@resolve_database_id, @resolve_file_id, @resolve_page_id, ''LIMITED'') AS pi; + END TRY + BEGIN CATCH + /* no VIEW DATABASE STATE / page reallocated -> leave this page for labeling */ + END CATCH;'; + + EXECUTE sys.sp_executesql + @resolve_sql, + N'@resolve_database_id integer, + @resolve_file_id integer, + @resolve_page_id bigint, + @resolve_object_id integer OUTPUT', + @resolve_database_id, + @resolve_file_id, + @resolve_page_id, + @resolve_object_id OUTPUT; + + IF @resolve_object_id IS NOT NULL + BEGIN + UPDATE + b + SET + b.resource_object_id = @resolve_object_id + FROM #blocks AS b + WHERE b.lock_type IN ('PAGE', 'RID') + AND b.resource_database_id = @resolve_database_id + AND b.resource_file_id = @resolve_file_id + AND b.resource_page_id = @resolve_page_id; + END; + + FETCH NEXT FROM @page_resolution_pages INTO @resolve_file_id, @resolve_page_id; + END; END; FETCH NEXT FROM @page_resolution_dbs INTO @resolve_database_id; END; @@ -2732,6 +2870,29 @@ SET FROM #blocks AS b OPTION(RECOMPILE); +/* +Inherit the victim's resolved object onto its paired lead-blocker row. +The blocker legitimately has no waitresource to decode — it holds the +lock rather than waiting on it — and its event-level object_id is +unreliable for KEY/RID/PAGE locks, so its row fell through to the +Unresolved sentinel even though the paired victim row in the SAME +report already named the exact object. Left unpaired, one physical +contention showed up TWICE in the per-object rollups: once under the +real object and once under Unresolved. +*/ +UPDATE + unresolved +SET + unresolved.contentious_object = resolved.contentious_object +FROM #blocks AS unresolved +JOIN #blocks AS resolved + ON resolved.monitor_loop = unresolved.monitor_loop + AND resolved.blocking_desc = unresolved.blocking_desc + AND resolved.blocked_desc = unresolved.blocked_desc + AND resolved.contentious_object NOT LIKE N'Unresolved%' +WHERE unresolved.contentious_object LIKE N'Unresolved%' +OPTION(RECOMPILE); + /*Either return results or log to a table*/ SET @dsql = N' SELECT @@ -2789,12 +2950,33 @@ AND (b.contentious_object = @object_name /* Add the WHERE clause only for table logging */ IF @log_to_table = 1 BEGIN - SET @mdsql = + DECLARE + @start_date_fallback datetime2(7) = + ISNULL + ( + @start_date_original, + DATEADD(DAY, -7, SYSDATETIME()) + ); + + /* + Substituting into a separate variable keeps @mdsql a pristine + template — the old code REPLACEd the name in and then tried to + REPLACE it back out afterwards, which breaks the template forever + if the table name happens to contain the placeholder text. The + literal placeholder gets the quote-doubled name because it lands + inside a quoted string; the identifier position takes it raw. + */ + SET @mdsql_execute = REPLACE ( REPLACE ( - @mdsql, + REPLACE + ( + @mdsql, + '{table_check_literal}', + REPLACE(@log_table_blocking, N'''', N'''''') + ), '{table_check}', @log_table_blocking ), @@ -2802,25 +2984,21 @@ BEGIN 'event_time' ); - IF @debug = 1 BEGIN PRINT @mdsql; END; + IF @debug = 1 BEGIN PRINT @mdsql_execute; END; + /* + The fallback rides in as the caller's LOCAL-domain window start — + @start_date_original is the raw parameter before the UTC shift, + matching the local-time event_time column the watermark compares + against; when the caller relied on the defaults, seven days back + mirrors the proc's own default window. + */ EXECUTE sys.sp_executesql - @mdsql, - N'@max_event_time datetime2(7) OUTPUT', - @max_event_time OUTPUT; - - SET @mdsql = - REPLACE - ( - REPLACE - ( - @mdsql, - @log_table_blocking, - '{table_check}' - ), - 'event_time', - '{date_column}' - ); + @mdsql_execute, + N'@max_event_time datetime2(7) OUTPUT, + @fallback_date datetime2(7)', + @max_event_time OUTPUT, + @fallback_date = @start_date_fallback; SET @dsql += N' AND b.event_time > @max_event_time'; @@ -3173,10 +3351,15 @@ BEGIN FROM #blocked WHERE event_time IS NOT NULL; - /* Use original dates if no data found */ + /* + Use original dates if no data found. The originals are NULL when + the caller relied on the default window, and a NULL here + concatenated the entire header finding away on an empty capture — + so fall through to the always-populated defaulted dates. + */ SELECT - @actual_start_date = ISNULL(@actual_start_date, @start_date_original), - @actual_end_date = ISNULL(@actual_end_date, @end_date_original), + @actual_start_date = ISNULL(@actual_start_date, ISNULL(@start_date_original, @start_date)), + @actual_end_date = ISNULL(@actual_end_date, ISNULL(@end_date_original, @end_date)), @actual_event_count = ISNULL(@actual_event_count, 0); IF @debug = 1 @@ -3246,7 +3429,7 @@ BEGIN N'Database Locks', finding = N'The database ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N' has been involved in ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' blocking sessions.', @@ -3358,7 +3541,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' select queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3406,7 +3589,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' repeatable read queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3448,7 +3631,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' serializable queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3490,7 +3673,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' sleeping queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3532,7 +3715,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' background queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3574,7 +3757,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' done queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3616,7 +3799,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' compile locks blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3658,7 +3841,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' application locks blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3700,7 +3883,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' implicit transaction queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3742,7 +3925,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' user transaction queries involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -3784,7 +3967,7 @@ BEGIN N'There have been ' + CONVERT(nvarchar(20), COUNT_BIG(DISTINCT b.transaction_id)) + N' auto stats updates involved in blocking sessions in ' + - b.database_name + + ISNULL(b.database_name, N'unknown') + N'.', sort_order = ROW_NUMBER() OVER (ORDER BY COUNT_BIG(DISTINCT b.transaction_id) DESC) @@ -4016,27 +4199,36 @@ BEGIN JOIN #session_leads AS sl ON sl.monitor_loop = b.monitor_loop AND sl.session_desc = b.blocking_desc - CROSS APPLY - ( - SELECT - contentious_object = - ISNULL - ( - OBJECT_SCHEMA_NAME(b.object_id, b.database_id) + - N'.' + - OBJECT_NAME(b.object_id, b.database_id), - N'' - ) - ) AS co WHERE ( b.database_name = @database_name OR @database_name IS NULL ) + /* + The object filter reads the wait_resource-decoded + contentious_object from #blocks — the SAME resolution every + other check filters on — via EXISTS so repeat BPR fires for one + victim cannot fan this row out. It used to re-derive the name + from the event-level object_id, which is unreliable for + KEY/RID/PAGE locks (the resolution comment on #blocks says as + much), so OBJECT_NAME returned NULL, the name collapsed to '', + and any real @object_name filtered the Top Lead Blocker finding + out of existence while every other check still showed the + object. + */ AND ( - co.contentious_object = @object_name - OR @object_name IS NULL + @object_name IS NULL + OR EXISTS + ( + SELECT + 1/0 + FROM #blocks AS bck + WHERE bck.monitor_loop = b.monitor_loop + AND bck.blocked_desc = b.blocked_desc + AND bck.activity = 'blocked' + AND bck.contentious_object = @object_name + ) ) ), lead_sql AS @@ -4285,19 +4477,22 @@ BEGIN pv.wait_time_ms ) ) / + /* + The denominator is a correlated subquery over + the whole per_victim set — the same grand total + the HAVING threshold uses — because a windowed + SUM() OVER () here is computed AFTER the HAVING + filter, so the "% of total" was a share of only + the surviving groups and overstated every + percentage. + */ NULLIF ( - SUM ( - SUM - ( - CONVERT - ( - bigint, - pv.wait_time_ms - ) - ) - ) OVER (), + SELECT + SUM(CONVERT(bigint, pv2.wait_time_ms)) + FROM per_victim AS pv2 + ), 0 ) ) @@ -4414,6 +4609,14 @@ BEGIN OR @database_name IS NULL) AND (b.contentious_object = @object_name OR @object_name IS NULL) + /* + Victim rows only: blocking-activity rows carry the blocker's + OWN wait (a WAITFOR, an I/O, being blocked elsewhere), which is + not lock wait on this resource — and both activities share the + event's transaction_id, so without this the MAX could pick the + blocker's unrelated wait and report it as lock wait time. + */ + AND b.activity = 'blocked' GROUP BY b.database_name, b.transaction_id @@ -4502,6 +4705,8 @@ BEGIN OR @database_name IS NULL) AND (b.contentious_object = @object_name OR @object_name IS NULL) + /* Victim rows only — same reasoning as check_id 1000 above. */ + AND b.activity = 'blocked' GROUP BY b.database_name, b.contentious_object, diff --git a/sp_HumanEvents/tests/README.md b/sp_HumanEvents/tests/README.md new file mode 100644 index 00000000..a27de399 --- /dev/null +++ b/sp_HumanEvents/tests/README.md @@ -0,0 +1,194 @@ +# sp_HumanEvents Tests + +`sp_HumanEvents` builds an Extended Events session out of dynamic SQL, samples +events for `@seconds_sample` seconds, then shreds and returns what it captured. +Event types it understands (confirmed against the validation list in the proc): + +| Category | Accepted `@event_type` spellings | Events the session adds (SQL 2022) | +| --- | --- | --- | +| query | `query`, `queries` | `module_end`, `rpc_completed`, `sp_statement_completed`, `sql_statement_completed`, `query_post_execution_showplan` (unless `@skip_plans = 1`) | +| waits | `waits`, `wait` | `wait_completed` (2014+) / `wait_info` (2012) | +| blocking | `blocking`, `locking`, `block`, `blocks`, `lock`, `locks` | `blocked_process_report` | +| compiles | `compiles`, `compile`, `compilation`, `compilations` | `sql_statement_post_compile` (2017+) or `uncached_sql_batch_statistics` + `sql_statement_recompile` (older); plus `query_parameterization_data` where available | +| recompiles | `recompiles`, `recompile`, `recompilation`, `recompilations` | `sql_statement_post_compile` (2017+) or `sql_statement_recompile` (older) | + +**Run the automated suite before and after any change to `sp_HumanEvents.sql`.** +Compiling proves nothing about behavior. This suite catches the proc's +characteristic failure directly: **a generated `CREATE EVENT SESSION` that will +not run** for some event-type / filter-parameter combination on the target +server version. + +## The hard problem: don't test live event content + +Almost everything `sp_HumanEvents` *returns* depends on **volatile, timing-bound +workload** -- which queries happened to run, which waits happened to fire, which +blocking happened to occur during the few-second sample. Asserting on captured +rows produces a suite that flakes, and a flaky suite is worse than none. So this +harness does **not** assert on captured event rows. It asserts on the thing that +actually breaks when the proc regresses: **whether the dynamic DDL it generates +is valid and is accepted by SQL Server**, plus strict **session hygiene**. + +Two levers in the proc make that deterministic and fast, with no slow captures: + +- **`@keep_alive = 1`** creates a *permanent* session and `RETURN`s immediately + -- no `WAITFOR` sample, no drop (the `@keep_alive = 1` branch near line 1777). + A session that CREATEs successfully is proof the generated DDL is valid on this + server version. This is the high-value core. +- **`@debug = 1`** prints the generated `@session_sql` via `RAISERROR` (near line + 1768), so the exact DDL can be captured from stdout and inspected. + +## Automated + +| Script | What it does | +| --- | --- | +| `run_tests.py` | Drives `dbo.sp_HumanEvents` over `sqlcmd`, exercising every accepted `@event_type` and a representative filter matrix with `@keep_alive = 1`, and asserts **195** expectations across seven groups. Self-cleaning and idempotent. | + +``` +cd sp_HumanEvents/tests +python run_tests.py --server SQL2022 +``` + +`--server` and `--password` default to `SQL2022` / the standard local `sa` +password. Expect `195 passed, 0 failed`. The proc must be installed in `master` +on the target instance (there is a preflight that fails fast if it is not, and a +guard that refuses Azure SQL DB, whose sessions are database-scoped). + +The suite has been run green on **SQL Server 2016, 2022, and 2025** (and the +structural + DDL matrix is version-parameterized for 2017/2019 as well). The +expected event set for each `@event_type` is not hard-coded to a version: the +harness **probes the server** for `sql_statement_post_compile`, +`query_parameterization_data`, and the version number, and computes the expected +events accordingly, so it stays correct whether or not a given build exposes the +newer compile/parameterization events. + +### What it covers + +**1. Structural / smoke (6).** `@help = 1` returns help text and creates no +session; an invalid `@event_type` is rejected with the proc's validation message +(severity 11) and creates no session -- a bidirectional check that validation +actually fires, not just that valid input works. + +**2. DDL-validity matrix -- base (90).** For **each of the 18 accepted +`@event_type` spellings**, the harness runs `@keep_alive = 1` and asserts, five +ways: the call raised no severe SQL error; the proc itself raised no error; a +session with the expected generated name now exists in +`sys.server_event_sessions`; the session carries **exactly** the set of events +that event type is supposed to add (read from the authoritative catalog +`sys.server_event_session_events`, which cannot be truncated and proves SQL +Server actually *accepted* each event); and the session is then dropped and +confirmed gone. + +**3. DDL-validity matrix -- filters (60).** Twelve representative +filter-parameter combinations across the categories, each asserted the same five +ways. Between them they exercise `@query_duration_ms`, `@database_name`, +`@requested_memory_mb`, `@client_app_name`, `@client_hostname`, `@username`, +`@session_id = 'sample'` + `@sample_divisor`, `@object_name` (+ `@object_schema` +for the blocking object-id path), `@skip_plans = 1` (asserts the showplan event +is **dropped** -- four events, not five), `@custom_name` (asserts the session +name gains the custom suffix), `@wait_type` as a CSV and as `ALL`, +`@wait_duration_ms`, `@gimme_danger`, and `@blocking_duration_ms`. + +**4. Debug-DDL capture (21).** For each of the five categories, `@debug = 1` is +captured and the generated `@session_sql` is asserted to contain +`CREATE EVENT SESSION` plus each event the category adds. (`RAISERROR` truncates +a long message near 2044 characters, so the untruncated +`sys.server_event_session_events` check in groups 2 and 3 is the authoritative +one; this group is a secondary guard, plus a check that `@debug` emits its +diagnostic markers.) + +**5. Short live sample (6).** Two event types (`waits`, `query`) are run for real +with `@seconds_sample = 3` and `@keep_alive = 0` -- the full +create / sample / query / drop path. Each asserts no severe error, that the proc +**completed on an empty capture without erroring** (the common failure mode for a +quiet instance), and that the proc **dropped its own throwaway session**: the +global count of `HumanEvents%` / `keeper_HumanEvents%` sessions is snapshotted +before and after the call and must be equal. + +**6. Logging-to-table + cleanup (10).** `@keep_alive = 1` with +`@output_database_name` enters an **unbounded** collector loop that creates +permanent tables/views for keeper sessions and harvests into them forever. The +tables are created in the first pass (well under a second), so the harness runs +it with a `sqlcmd` query timeout (`-t`), which sends an attention that cancels +the loop **server-side** (verified: a batch cancelled this way does not keep +running). It then asserts the base table `keeper_HumanEvents_waits` and the three +`HumanEvents_Waits*` views were created, exercises the proc's own `@cleanup = 1` +teardown, and asserts the proc removed the session, tables, and views itself. +Everything is created in and dropped with a throwaway scratch database +(`sp_HumanEvents_test_scratch`). + +**7. Session hygiene (2).** Around the whole run, `sys.server_event_sessions` is +diffed and the suite asserts **zero net new sessions**, then confirms nothing +remains after the final sweep. The before/after lists and the diff are printed. + +### Session hygiene -- non-negotiable + +Extended Events sessions are server-global; a leaked running session is a real +problem. The harness treats that as a first-class requirement: + +- `sp_HumanEvents` names its sessions predictably -- + `HumanEvents__` (one-shot) and + `keeper_HumanEvents_[_]` (`@keep_alive = 1`). The + harness **sweeps both patterns at start** (idempotency: reap anything a prior + aborted run left) and **in a `finally` block at the end** (so leaks are cleaned + even when an assertion fails -- verified: a deliberately failing run still left + zero sessions behind). +- Each `@keep_alive` matrix case **drops its own session immediately** after its + assertions, so at most one session exists at a time. This is not just tidiness: + each session reserves a 100 MB ring buffer, and creating all 18 at once demanded + ~1.8 GB and drew `Msg 701` (out of memory). That is a **test artifact, not a + proc bug**; dropping-each-first avoids it. +- The short-sample cases assert the proc cleaned up **its own** session. +- The run prints the before/after session lists and asserts they are identical. + +### Why the assertions are trusted + +The event-set assertions are not rubber-stamps. The `@skip_plans = 1` case +expects **four** events and the plain query case expects **five**; both pass, +which is only possible if the comparison actually discriminates. As an explicit +negative control, injecting a bogus expected event was confirmed to turn the +suite **red** on exactly the affected cases (9 failures) -- and the `finally` +sweep still left **zero** leaked sessions on that failing run. Every event type +was also watched to create a real session and report its real event list before +the expected sets were written. + +## What is NOT covered, and why + +This is the honest part. A green run means the covered surface -- DDL validity +and hygiene -- works; it does not mean every behavior of `sp_HumanEvents` is +exercised. + +- **Captured event content.** By design (see above): which rows come back is + timing- and workload-bound and cannot be forced deterministically on a shared + instance. The live-sample group asserts the path *runs and cleans up*, not what + it returns. This is the large, deliberate gap. +- **`@target_output = 'event_file'`.** Only the default `ring_buffer` target is + exercised. The `event_file` target writes `.xel` files to the SQL Server's own + filesystem, and the `@keep_alive` path does not delete them (only the one-shot + path calls `xp_delete_files`). The harness cannot reliably locate and remove a + server-side file, so asserting on this target would either leave disk artifacts + or be flaky. Its DDL differs only in the `ADD TARGET` clause. +- **Azure SQL Database / Managed Instance.** The proc uses database-scoped + sessions and a different catalog there; the harness refuses to run against an + Azure engine edition rather than assert box-product invariants that do not hold. +- **The pre-2017 event fallbacks specifically.** The expected-event logic *is* + version-aware (it would expect `uncached_sql_batch_statistics` / + `sql_statement_recompile` / `wait_info` on a build that lacks the newer events), + but every instance available here -- including the 2016 build (13.0.6300) -- + exposes the newer `sql_statement_post_compile` / `query_parameterization_data` + events, so those specific fallback DDL strings were not observed to CREATE on a + box that actually lacks them. On a bare RTM 2016/2012 the harness would compute + the fallback set; that path has not been watched create. +- **Non-`dbo` `@output_schema_name`.** The logging tests use `dbo`. The proc's + branch that validates a non-default output schema is not exercised. +- **Blocking actually captured.** The blocking session's DDL validity is covered; + inducing a real blocked-process-report event is not. + +## Not wired into CI + +Like the other DarlingData behavioral suites, this is **not** part of any GitHub +Actions workflow. It creates and drops server-global Extended Events sessions +(and, for the blocking cases, will briefly set `blocked process threshold` if it +is `0`, restoring it afterward), which is not something to run against shared CI +infrastructure. Run it by hand against a test instance you own. It sweeps every +session it creates and drops every object it creates, but it is a "run it on a +box you control" tool by nature. diff --git a/sp_HumanEvents/tests/run_tests.py b/sp_HumanEvents/tests/run_tests.py new file mode 100644 index 00000000..b2e72ca2 --- /dev/null +++ b/sp_HumanEvents/tests/run_tests.py @@ -0,0 +1,806 @@ +""" +sp_HumanEvents DDL-validity and session-hygiene test harness +============================================================ +sp_HumanEvents builds an Extended Events session out of dynamic SQL, samples +events for @seconds_sample seconds, then shreds and returns what it captured. +Its characteristic failure is generating a malformed CREATE EVENT SESSION for +some @event_type / filter-parameter combination -- a "generated script that will +not run" on the target server version. Live event CONTENT is timing-dependent +and thin, so this harness does NOT assert on captured rows; it asserts on the +thing that actually breaks: whether the generated DDL is accepted by SQL Server. + +Two levers make that deterministic and fast, with no slow live captures: + + * @keep_alive = 1 creates a PERMANENT session and RETURNs immediately -- no + WAITFOR sample, no drop (see the @keep_alive = 1 branch around line 1777 of + sp_HumanEvents.sql). A session that CREATEs successfully is proof the + generated DDL is valid on this server version. This is the high-value core: + for every accepted @event_type, and for representative filter parameters, + the harness runs @keep_alive = 1, asserts the call raised no error, asserts + the named session now exists, asserts the session carries exactly the events + that @event_type is supposed to add (read from the authoritative catalog + sys.server_event_session_events, which cannot be truncated and proves SQL + Server actually accepted each event), then DROPs the session and asserts it + is gone. + + * @debug = 1 prints the generated @session_sql via RAISERROR (around line + 1768). The harness captures that per event category and asserts it contains + CREATE EVENT SESSION plus the events expected for the category. (RAISERROR + truncates a long message near 2044 chars, so the untruncated catalog check + above is the authoritative one; the debug-text check is a secondary guard.) + +Session hygiene is non-negotiable. Every session this proc or this harness +creates is dropped, even on assertion failure: + + * sp_HumanEvents names its sessions predictably: + HumanEvents__ (one-shot, @keep_alive = 0) + keeper_HumanEvents_[_] (@keep_alive = 1) + The harness sweeps BOTH name patterns at start (idempotency: reap anything a + prior aborted run left) and in a finally block at the end. + * Each @keep_alive matrix case drops its own session immediately after the + assertions, so at most one 100MB-ring-buffer session exists at a time. + (Creating all of them at once demanded ~1.8 GB and drew Msg 701 -- a test + artifact, not a proc bug; dropping-each-first avoids it.) + * The short live-sample cases confirm the proc dropped its OWN session: the + global session count is snapshotted before and after and must be equal. + * Around the whole run, sys.server_event_sessions is diffed to prove zero net + new sessions, and the diff is printed. + +Usage: + python run_tests.py [--server SQL2022] [--password L!nt0044] + +Exits 1 if any assertion fails. +""" + +import argparse +import os +import re +import shlex +import subprocess +import sys + + +# ---------------------------------------------------------------- error scanning + +def find_sql_errors(text): + """Return any SQL errors of severity 16 or higher found in text. + + go-sqlcmd reports errors on stdout, so callers pass BOTH streams. Matching + the severity numerically catches Level 16 through 19 rather than only the + literal "Level 16". Note: sp_HumanEvents reports user-input problems with + RAISERROR severity 11 (invalid @event_type, missing blocked process report, + etc.); those are intentionally NOT matched here -- they are asserted on by + text where relevant. A malformed CREATE EVENT SESSION shows up as a failed + create (session absent) rather than only as a severe Msg, so the presence + checks below are the real gate. + """ + if not text: + return [] + return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text) + + +# ---------------------------------------------------------------- sqlcmd plumbing + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment so + one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the + go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN + at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + self-signed cert and disable encryption, since the modern Go TLS stack + rejects the SQL Server 2017 container's certificate outright.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + +def _sqlcmd(server, password, sql, database="master", + query_timeout=None, subprocess_timeout=120): + """Run a batch and return (stdout, stderr) decoded as UTF-8. + + Capturing bytes and decoding UTF-8 keeps any non-ASCII output from being + mangled by the Windows console code page. -h -1 drops result-set headers so + the RESULT| marker lines the batches emit are the only structured output. + query_timeout maps to sqlcmd -t (a query timeout that sends an attention and + cancels the batch server-side -- used to stop the never-ending collector + loop); subprocess_timeout is a hard backstop on the whole process. + """ + cmd = _sqlcmd_prefix() + [ + "-S", server, "-U", "sa", "-P", password, + "-d", database, + "-W", # trim trailing spaces + "-h", "-1", # no result-set headers + "-y", "0", # unlimited variable column width (no truncation) + "-w", "65535", # do not wrap wide rows + ] + if query_timeout is not None: + cmd += ["-t", str(query_timeout)] + cmd += ["-Q", sql] + try: + r = subprocess.run(cmd, capture_output=True, timeout=subprocess_timeout) + except subprocess.TimeoutExpired as e: + out = (e.stdout or b"").decode("utf-8", errors="replace") + err = (e.stderr or b"").decode("utf-8", errors="replace") + return out, err + "\n[subprocess timeout]" + out = (r.stdout or b"").decode("utf-8", errors="replace") + err = (r.stderr or b"").decode("utf-8", errors="replace") + return out, err + + +def _esc(s): + """Escape a T-SQL single-quoted literal.""" + return str(s).replace("'", "''") + + +# ---------------------------------------------------------------- RESULT parsing + +def parse_results(out): + """Collect the RESULT| marker lines a batch emits into a dict.""" + d = { + "events": set(), "proc_ok": False, "proc_error": None, + "exists": None, "dropped": None, "gone": None, + "before": None, "after": None, "tables": set(), "views": set(), + "caps": {}, + } + for line in out.splitlines(): + line = line.strip() + if not line.startswith("RESULT|"): + continue + parts = line.split("|", 2) + key = parts[1] if len(parts) > 1 else "" + val = parts[2] if len(parts) > 2 else "" + if key == "event": + d["events"].add(val) + elif key == "table": + d["tables"].add(val) + elif key == "view": + d["views"].add(val) + elif key == "proc_ok": + d["proc_ok"] = (val == "1") + elif key == "proc_error": + d["proc_error"] = val + elif key == "exists": + d["exists"] = (val == "1") + elif key == "dropped": + d["dropped"] = (val == "1") + elif key == "gone": + d["gone"] = (val == "1") + elif key in ("before", "after"): + try: + d[key] = int(val) + except ValueError: + d[key] = None + elif key == "cap": + sub = val.split("|", 1) + if len(sub) == 2: + d["caps"][sub[0]] = sub[1] + return d + + +# ---------------------------------------------------------------- event mapping + +def category_of(event_type): + """Replicate the proc's event-type routing (the CASE around line 1650): + %lock% then %quer% then %wait% then %recomp% then (%comp% and not %re%). + Order matters; this mirrors it exactly.""" + et = event_type.lower() + if "lock" in et: + return "blocking" + if "quer" in et: + return "query" + if "wait" in et: + return "waits" + if "recomp" in et: + return "recompiles" + if "comp" in et and "re" not in et: + return "compiles" + return None + + +def expected_events(event_type, caps, skip_plans=False): + """Return the SET of event names sp_HumanEvents should attach for this + event_type on this server, version-aware. Verified live against + sys.server_event_session_events on SQL Server 2022.""" + cat = category_of(event_type) + v = caps["v"] + ce = caps["compile_events"] + pe = caps["param_events"] + if cat == "query": + evs = ["module_end", "rpc_completed", + "sp_statement_completed", "sql_statement_completed"] + if not skip_plans: + evs.append("query_post_execution_showplan") + return set(evs) + if cat == "waits": + # proc keys purely on @v: wait_completed on 2014+, wait_info on 2012. + return set(["wait_completed"]) if v > 11 else set(["wait_info"]) + if cat == "blocking": + return set(["blocked_process_report"]) + if cat == "compiles": + if ce: + evs = ["sql_statement_post_compile"] + else: + evs = ["uncached_sql_batch_statistics", "sql_statement_recompile"] + if pe: + evs.append("query_parameterization_data") + return set(evs) + if cat == "recompiles": + return set(["sql_statement_post_compile"]) if ce else set(["sql_statement_recompile"]) + return set() + + +def session_name(event_type, custom_name=None): + n = "keeper_HumanEvents_" + event_type.lower() + if custom_name: + n += "_" + custom_name + return n + + +def build_params(params): + """Turn [(name, value, kind)] into a T-SQL argument tail.""" + s = "" + for (name, value, kind) in params: + if kind == "str": + s += ", %s = N'%s'" % (name, _esc(value)) + else: # int / bit + s += ", %s = %s" % (name, value) + return s + + +# ---------------------------------------------------------------- session sweeps + +SWEEP_PREDICATE = ( + "s.name LIKE N'HumanEvents[_]%' OR s.name LIKE N'keeper[_]HumanEvents[_]%'" +) + + +def list_he_sessions(server, password): + """Return the sorted list of sp_HumanEvents-owned session names present.""" + sql = ( + "SET NOCOUNT ON; SELECT line = 'RESULT|event|' + s.name " + "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";" + ) + out, _ = _sqlcmd(server, password, sql) + return sorted(parse_results(out)["events"]) + + +def sweep_he_sessions(server, password): + """Drop every sp_HumanEvents-owned session. These names are created only by + sp_HumanEvents (naming contract documented in the proc's own cleanup), so + sweeping them is safe and makes the suite idempotent.""" + sql = ( + "SET NOCOUNT ON; DECLARE @drop nvarchar(max) = N''; " + "SELECT @drop += N'DROP EVENT SESSION ' + s.name + N' ON SERVER;' + NCHAR(10) " + "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + "; " + "IF LEN(@drop) > 0 EXECUTE sys.sp_executesql @drop;" + ) + return _sqlcmd(server, password, sql) + + +# ---------------------------------------------------------------- batch builders + +def keepalive_batch(event_type, sname, params_sql): + """Create a keep_alive session, report existence + its events, drop it, + report gone -- all as RESULT| marker lines.""" + exists_expr = ( + "CASE WHEN EXISTS (SELECT 1 FROM sys.server_event_sessions AS s " + "WHERE s.name = N'" + sname + "') THEN 1 ELSE 0 END" + ) + return ( + "SET NOCOUNT ON;\n" + "BEGIN TRY\n" + " EXECUTE dbo.sp_HumanEvents @event_type = N'" + event_type + "', " + "@keep_alive = 1" + params_sql + ";\n" + " SELECT line = 'RESULT|proc_ok|1';\n" + "END TRY\n" + "BEGIN CATCH\n" + " SELECT line = 'RESULT|proc_error|' + " + "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n" + "END CATCH;\n" + "SELECT line = 'RESULT|exists|' + CONVERT(varchar(2), " + exists_expr + ");\n" + "SELECT line = 'RESULT|event|' + sese.name " + "FROM sys.server_event_sessions AS ses " + "JOIN sys.server_event_session_events AS sese " + "ON sese.event_session_id = ses.event_session_id " + "WHERE ses.name = N'" + sname + "';\n" + "IF EXISTS (SELECT 1 FROM sys.server_event_sessions AS s WHERE s.name = N'" + sname + "')\n" + "BEGIN\n" + " BEGIN TRY EXECUTE (N'DROP EVENT SESSION " + sname + " ON SERVER;'); " + "SELECT line = 'RESULT|dropped|1'; END TRY\n" + " BEGIN CATCH SELECT line = 'RESULT|dropped|0'; END CATCH;\n" + "END;\n" + "SELECT line = 'RESULT|gone|' + CONVERT(varchar(2), " + "CASE WHEN EXISTS (SELECT 1 FROM sys.server_event_sessions AS s " + "WHERE s.name = N'" + sname + "') THEN 0 ELSE 1 END);\n" + ) + + +# ---------------------------------------------------------------- results object + +class Results: + def __init__(self): + self.items = [] + + def check(self, group, name, condition, detail=""): + self.items.append({ + "group": group, + "name": name, + "passed": bool(condition), + "detail": detail, + }) + + @property + def passed(self): + return sum(1 for r in self.items if r["passed"]) + + @property + def failed(self): + return sum(1 for r in self.items if not r["passed"]) + + +# ---------------------------------------------------------------- capability probe + +def detect_caps(server, password): + """Read version and XE capabilities so the expected-event sets are correct + for whatever version we are pointed at.""" + sql = ( + "SET NOCOUNT ON;\n" + "SELECT line = 'RESULT|cap|installed|' + CASE WHEN OBJECT_ID(N'dbo.sp_HumanEvents', N'P') IS NOT NULL THEN '1' ELSE '0' END;\n" + "SELECT line = 'RESULT|cap|v|' + CONVERT(varchar(11), CONVERT(integer, PARSENAME(CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')), 4)));\n" + "SELECT line = 'RESULT|cap|mv|' + CONVERT(varchar(11), CONVERT(integer, PARSENAME(CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')), 2)));\n" + "SELECT line = 'RESULT|cap|azure|' + CASE WHEN CONVERT(integer, SERVERPROPERTY('EngineEdition')) = 5 THEN '1' ELSE '0' END;\n" + "SELECT line = 'RESULT|cap|compile_events|' + CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects AS o WHERE o.name = N'sql_statement_post_compile') THEN '1' ELSE '0' END;\n" + "SELECT line = 'RESULT|cap|param_events|' + CASE WHEN EXISTS (SELECT 1 FROM sys.dm_xe_objects AS o WHERE o.name = N'query_parameterization_data') THEN '1' ELSE '0' END;\n" + "SELECT line = 'RESULT|cap|bpr|' + CONVERT(varchar(11), ISNULL((SELECT CONVERT(integer, c.value_in_use) FROM sys.configurations AS c WHERE c.name = N'blocked process threshold (s)'), -1));\n" + ) + out, err = _sqlcmd(server, password, sql) + raw = parse_results(out)["caps"] + return { + "installed": raw.get("installed") == "1", + "v": int(raw.get("v", "0") or "0"), + "mv": int(raw.get("mv", "0") or "0"), + "azure": raw.get("azure") == "1", + "compile_events": raw.get("compile_events") == "1", + "param_events": raw.get("param_events") == "1", + "bpr": int(raw.get("bpr", "-1") or "-1"), + }, (out, err) + + +# ---------------------------------------------------------------- config helpers + +def get_config(server, password, name): + sql = ( + "SET NOCOUNT ON; SELECT line = 'RESULT|before|' + " + "CONVERT(varchar(11), CONVERT(integer, c.value_in_use)) " + "FROM sys.configurations AS c WHERE c.name = N'" + _esc(name) + "';" + ) + out, _ = _sqlcmd(server, password, sql) + return parse_results(out)["before"] + + +def set_config(server, password, name, value): + sql = ( + "SET NOCOUNT ON; EXECUTE sys.sp_configure '" + _esc(name) + "', " + + str(value) + "; RECONFIGURE;" + ) + return _sqlcmd(server, password, sql) + + +# ---------------------------------------------------------------- test groups + +def matrix_case(server, password, R, group, label, event_type, + params, caps, skip_plans=False, custom_name=None): + sname = session_name(event_type, custom_name) + params_sql = build_params(params) + if skip_plans: + params_sql += ", @skip_plans = 1" + if custom_name: + params_sql += ", @custom_name = N'" + _esc(custom_name) + "'" + out, err = _sqlcmd(server, password, keepalive_batch(event_type, sname, params_sql)) + combined = out + "\n" + err + d = parse_results(out) + exp = expected_events(event_type, caps, skip_plans) + + R.check(group, "%s: no severe SQL error" % label, + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check(group, "%s: proc raised no error" % label, + d["proc_ok"] and not d["proc_error"], + "proc_error=%s" % d["proc_error"]) + R.check(group, "%s: session '%s' created" % (label, sname), + d["exists"] is True, "exists=%s" % d["exists"]) + R.check(group, "%s: events == %s" % (label, sorted(exp)), + d["events"] == exp, + "got %s" % sorted(d["events"])) + R.check(group, "%s: session dropped and gone" % label, + d["gone"] is True, "gone=%s dropped=%s" % (d["gone"], d["dropped"])) + # Belt-and-suspenders: nothing left behind by THIS case. + return sname + + +def group_matrix_base(server, password, R, caps): + all_types = [ + "waits", "blocking", "locking", "queries", "compiles", "recompiles", + "wait", "block", "blocks", "lock", "locks", "query", + "compile", "recompile", "compilation", "recompilation", + "compilations", "recompilations", + ] + for et in all_types: + matrix_case(server, password, R, "Matrix-Base", et, et, [], caps) + + +def group_matrix_filters(server, password, R, caps): + cases = [ + ("query+duration+db", "query", + [("@query_duration_ms", 1000, "int"), ("@database_name", "master", "str")], {}), + ("query+memory+app+host+user", "query", + [("@requested_memory_mb", 512, "int"), + ("@client_app_name", "HE_TestApp", "str"), + ("@client_hostname", "HE_TestHost", "str"), + ("@username", "HE_TestUser", "str")], {}), + ("query+session_sample", "query", + [("@session_id", "sample", "str"), ("@sample_divisor", 3, "int")], {}), + ("query+object", "query", + [("@object_name", "spt_values", "str")], {}), + ("query+skip_plans", "query", [], {"skip_plans": True}), + ("query+custom_name", "query", [], {"custom_name": "unittest"}), + ("waits+types+duration", "waits", + [("@wait_type", "SOS_SCHEDULER_YIELD,CXPACKET", "str"), + ("@wait_duration_ms", 5, "int")], {}), + ("waits+all+danger", "waits", + [("@wait_type", "ALL", "str"), ("@gimme_danger", 1, "bit")], {}), + ("blocking+duration", "blocking", + [("@blocking_duration_ms", 5000, "int")], {}), + ("blocking+object", "blocking", + [("@database_name", "master", "str"), + ("@object_name", "spt_values", "str"), + ("@object_schema", "dbo", "str"), + ("@blocking_duration_ms", 1000, "int")], {}), + ("compiles+app", "compiles", + [("@client_app_name", "HE_TestApp", "str")], {}), + ("recompiles+db", "recompiles", + [("@database_name", "master", "str")], {}), + ] + for (label, et, params, opts) in cases: + matrix_case(server, password, R, "Matrix-Filter", label, et, params, caps, + skip_plans=opts.get("skip_plans", False), + custom_name=opts.get("custom_name")) + + +def group_debug_ddl(server, password, R, caps): + """Capture the @debug = 1 generated @session_sql per event category and + assert it contains CREATE EVENT SESSION and each expected event.""" + reps = ["query", "waits", "blocking", "compiles", "recompiles"] + first = True + for et in reps: + sname = session_name(et) + batch = ( + "SET NOCOUNT ON;\n" + "EXECUTE dbo.sp_HumanEvents @event_type = N'" + et + "', " + "@keep_alive = 1, @debug = 1;\n" + "IF EXISTS (SELECT 1 FROM sys.server_event_sessions AS s WHERE s.name = N'" + sname + "') " + "EXECUTE (N'DROP EVENT SESSION " + sname + " ON SERVER;');\n" + ) + out, err = _sqlcmd(server, password, batch) + combined = out + "\n" + err + exp = expected_events(et, caps) + R.check("DebugDDL", "%s: no severe SQL error" % et, + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("DebugDDL", "%s: debug prints CREATE EVENT SESSION for %s" % (et, sname), + ("CREATE EVENT SESSION" in combined) and (sname in combined), + "CREATE EVENT SESSION or session name not found in debug output") + for ev in sorted(exp): + R.check("DebugDDL", "%s: debug DDL names event %s" % (et, ev), + ev in combined, "event %s not present in debug text" % ev) + if first: + R.check("DebugDDL", "%s: @debug emits diagnostic markers" % et, + "Setting up the event session" in combined, + "expected diagnostic marker not found") + first = False + + +def group_smoke(server, password, R, caps): + # ---- @help = 1 ------------------------------------------------------- + help_batch = ( + "SET NOCOUNT ON;\n" + "EXECUTE dbo.sp_HumanEvents @help = 1;\n" + "SELECT line = 'RESULT|after|' + CONVERT(varchar(11), COUNT_BIG(*)) " + "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";\n" + ) + out, err = _sqlcmd(server, password, help_batch) + combined = out + "\n" + err + d = parse_results(out) + R.check("Smoke", "@help = 1: no severe SQL error", + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("Smoke", "@help = 1: returns help text", + "allow me to reintroduce myself" in combined, + "help introduction text not found") + R.check("Smoke", "@help = 1: creates no session", + d["after"] == 0, "session count after @help = %s" % d["after"]) + + # ---- invalid @event_type -------------------------------------------- + bad_batch = ( + "SET NOCOUNT ON;\n" + "BEGIN TRY\n" + " EXECUTE dbo.sp_HumanEvents @event_type = N'garbage', @keep_alive = 1;\n" + " SELECT line = 'RESULT|proc_ok|1';\n" + "END TRY\n" + "BEGIN CATCH\n" + " SELECT line = 'RESULT|proc_error|' + " + "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n" + "END CATCH;\n" + "SELECT line = 'RESULT|after|' + CONVERT(varchar(11), COUNT_BIG(*)) " + "FROM sys.server_event_sessions AS s WHERE s.name = N'keeper_HumanEvents_garbage';\n" + ) + out, err = _sqlcmd(server, password, bad_batch) + combined = out + "\n" + err + d = parse_results(out) + R.check("Smoke", "invalid @event_type: no severe SQL error (rejected at sev 11)", + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("Smoke", "invalid @event_type: rejected with the validation message", + "What on earth" in combined, + "expected @event_type validation message not found") + R.check("Smoke", "invalid @event_type: no session created", + d["after"] == 0, "a keeper_HumanEvents_garbage session exists") + + +def group_live_sample(server, password, R, caps): + """The full create/sample/query/drop path with a small @seconds_sample. + Empty capture is expected and must NOT error, and the proc must drop its + own throwaway session (global session count before == after).""" + cases = [ + ("waits", [("@wait_duration_ms", 1, "int")]), + ("query", [("@query_duration_ms", 0, "int")]), + ] + for (et, params) in cases: + batch = ( + "SET NOCOUNT ON;\n" + "SELECT line = 'RESULT|before|' + CONVERT(varchar(11), COUNT_BIG(*)) " + "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";\n" + "BEGIN TRY\n" + " EXECUTE dbo.sp_HumanEvents @event_type = N'" + et + "', " + "@seconds_sample = 3" + build_params(params) + ";\n" + " SELECT line = 'RESULT|proc_ok|1';\n" + "END TRY\n" + "BEGIN CATCH\n" + " SELECT line = 'RESULT|proc_error|' + " + "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n" + "END CATCH;\n" + "SELECT line = 'RESULT|after|' + CONVERT(varchar(11), COUNT_BIG(*)) " + "FROM sys.server_event_sessions AS s WHERE " + SWEEP_PREDICATE + ";\n" + ) + out, err = _sqlcmd(server, password, batch, subprocess_timeout=60) + combined = out + "\n" + err + d = parse_results(out) + R.check("LiveSample", "%s 3s sample: no severe SQL error" % et, + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("LiveSample", "%s 3s sample: proc completed (empty capture OK)" % et, + d["proc_ok"] and not d["proc_error"], + "proc_error=%s" % d["proc_error"]) + R.check("LiveSample", "%s 3s sample: proc dropped its own session (net 0)" % et, + d["before"] is not None and d["after"] == d["before"], + "before=%s after=%s" % (d["before"], d["after"])) + + +def group_collector(server, password, R, caps): + """Logging-to-table mode: @keep_alive = 1 with @output_database_name enters + an unbounded collector loop that creates permanent tables/views for keeper + sessions and harvests into them forever. The tables are created in the first + pass (well under a second), so the harness runs it with a query timeout + (-t) that sends an attention and cleanly cancels the loop server-side, then + asserts the logging objects were created. It then exercises the proc's own + @cleanup = 1 teardown path and asserts the proc removed the session, tables, + and views itself. Everything is created in and dropped with a throwaway + scratch database. + + (This group runs last, after every matrix case has dropped its own session, + so no other keeper_HumanEvents_ session exists when @cleanup = 1 -- which + sweeps keeper sessions server-wide -- is invoked.)""" + db = "sp_HumanEvents_test_scratch" + sname = "keeper_HumanEvents_waits" + drop_db = ( + "IF DB_ID('" + db + "') IS NOT NULL BEGIN " + "ALTER DATABASE [" + db + "] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " + "DROP DATABASE [" + db + "]; END;" + ) + # Idempotent setup. + out, err = _sqlcmd(server, password, + "SET NOCOUNT ON; " + drop_db + " CREATE DATABASE [" + db + "];") + setup_err = find_sql_errors(out) + find_sql_errors(err) + R.check("Collector", "setup: scratch database created", not setup_err, str(setup_err)) + if setup_err: + _sqlcmd(server, password, "SET NOCOUNT ON; " + drop_db) + return + + want_views = {"HumanEvents_WaitsByDatabase", + "HumanEvents_WaitsByQueryAndDatabase", + "HumanEvents_WaitsTotal"} + obj_chk = ( + "SET NOCOUNT ON;\n" + "SELECT line = 'RESULT|table|' + t.name FROM sys.tables AS t;\n" + "SELECT line = 'RESULT|view|' + v.name FROM sys.views AS v;\n" + ) + + try: + collector = ( + "SET NOCOUNT ON; EXECUTE dbo.sp_HumanEvents " + "@event_type = N'waits', @keep_alive = 1, @wait_duration_ms = 1, " + "@output_database_name = N'" + db + "', @output_schema_name = N'dbo';" + ) + # -t 15: the loop never returns on its own; time it out. Table creation + # happens in the first pass, long before this fires. + out, err = _sqlcmd(server, password, collector, + query_timeout=15, subprocess_timeout=60) + combined = out + "\n" + err + # The timeout itself is not a severe Msg; only real errors count. + R.check("Collector", "collector run: no severe SQL error", + not find_sql_errors(combined), str(find_sql_errors(combined))) + + out2, err2 = _sqlcmd(server, password, obj_chk, database=db) + d = parse_results(out2) + R.check("Collector", "collector created base table keeper_HumanEvents_waits", + "keeper_HumanEvents_waits" in d["tables"], + "tables=%s" % sorted(d["tables"])) + R.check("Collector", "collector created waits views (%s)" % sorted(want_views), + want_views <= d["views"], "views=%s" % sorted(d["views"])) + + # ----- exercise the proc's own @cleanup = 1 teardown ------------ + cleanup = ( + "SET NOCOUNT ON;\n" + "BEGIN TRY\n" + " EXECUTE dbo.sp_HumanEvents @event_type = N'waits', @cleanup = 1, " + "@output_database_name = N'" + db + "', @output_schema_name = N'dbo';\n" + " SELECT line = 'RESULT|proc_ok|1';\n" + "END TRY\n" + "BEGIN CATCH\n" + " SELECT line = 'RESULT|proc_error|' + " + "LEFT(REPLACE(REPLACE(ERROR_MESSAGE(), CHAR(13), N' '), CHAR(10), N' '), 240);\n" + "END CATCH;\n" + ) + outc, errc = _sqlcmd(server, password, cleanup) + dc = parse_results(outc) + R.check("Collector", "@cleanup = 1: no severe SQL error", + not find_sql_errors(outc + "\n" + errc), + str(find_sql_errors(outc + "\n" + errc))) + R.check("Collector", "@cleanup = 1: proc raised no error", + dc["proc_ok"] and not dc["proc_error"], + "proc_error=%s" % dc["proc_error"]) + + out3, _ = _sqlcmd(server, password, obj_chk, database=db) + d3 = parse_results(out3) + R.check("Collector", "@cleanup = 1 removed the logging tables", + "keeper_HumanEvents_waits" not in d3["tables"], + "tables still present: %s" % sorted(d3["tables"])) + R.check("Collector", "@cleanup = 1 removed the logging views", + not (want_views & d3["views"]), + "views still present: %s" % sorted(d3["views"] & want_views)) + out4, _ = _sqlcmd(server, password, + "SET NOCOUNT ON; SELECT line = 'RESULT|gone|' + CONVERT(varchar(2), " + "CASE WHEN EXISTS (SELECT 1 FROM sys.server_event_sessions AS s " + "WHERE s.name = N'" + sname + "') THEN 0 ELSE 1 END);") + R.check("Collector", "@cleanup = 1 dropped the keeper session", + parse_results(out4)["gone"] is True, + "keeper session still present after @cleanup") + finally: + # Safety net: drop any residual session and the scratch database even if + # the collector or the @cleanup path failed partway through. + _sqlcmd(server, password, + "SET NOCOUNT ON; " + "IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = N'" + sname + "') " + "DROP EVENT SESSION " + sname + " ON SERVER; " + drop_db) + out, _ = _sqlcmd(server, password, + "SET NOCOUNT ON; SELECT line = 'RESULT|after|' + CONVERT(varchar(11), " + "(SELECT COUNT_BIG(*) FROM sys.databases WHERE name = N'" + db + "') + " + "(SELECT COUNT_BIG(*) FROM sys.server_event_sessions WHERE name = N'" + sname + "'));") + left = parse_results(out)["after"] + R.check("Collector", "cleanup: scratch database and keeper session dropped", + left == 0, "residual objects = %s" % left) + + +# ---------------------------------------------------------------- main + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server", default="SQL2022") + ap.add_argument("--password", default="L!nt0044") + args = ap.parse_args() + + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + print("Running sp_HumanEvents DDL-validity tests against %s..." % args.server) + print() + + caps, _raw = detect_caps(args.server, args.password) + if not caps["installed"]: + print("ERROR: dbo.sp_HumanEvents is not installed in master on %s." % args.server) + print("Install sp_HumanEvents.sql before running this harness.") + sys.exit(1) + if caps["azure"]: + print("ERROR: this harness targets a box-product instance " + "(server-scoped sessions); the target reports Azure SQL DB.") + sys.exit(1) + + print("Server: v=%d mv=%d compile_events=%s param_events=%s bpr=%d" + % (caps["v"], caps["mv"], caps["compile_events"], + caps["param_events"], caps["bpr"])) + + R = Results() + + # ----- session hygiene: idempotent start ------------------------------ + sweep_he_sessions(args.server, args.password) + before = list_he_sessions(args.server, args.password) + print("Sessions before run (post-sweep): %s" % (before if before else "(none)")) + print() + + # ----- blocked process report: blocking needs threshold > 0 ---------- + touched_bpr = False + orig_show = None + if caps["bpr"] == 0: + orig_show = get_config(args.server, args.password, "show advanced options") + set_config(args.server, args.password, "show advanced options", 1) + set_config(args.server, args.password, "blocked process threshold (s)", 5) + touched_bpr = True + print("blocked process threshold was 0; temporarily set to 5 for the " + "blocking cases (will be restored).") + print() + + try: + group_smoke(args.server, args.password, R, caps) + group_matrix_base(args.server, args.password, R, caps) + group_matrix_filters(args.server, args.password, R, caps) + group_debug_ddl(args.server, args.password, R, caps) + group_live_sample(args.server, args.password, R, caps) + group_collector(args.server, args.password, R, caps) + finally: + # ----- session hygiene: measure leaks, then sweep ---------------- + after_tests = list_he_sessions(args.server, args.password) + sweep_he_sessions(args.server, args.password) + after_final = list_he_sessions(args.server, args.password) + + # ----- restore blocked process report ---------------------------- + if touched_bpr: + set_config(args.server, args.password, "blocked process threshold (s)", 0) + set_config(args.server, args.password, "show advanced options", + orig_show if orig_show is not None else 0) + now_bpr = get_config(args.server, args.password, "blocked process threshold (s)") + R.check("Hygiene", "blocked process threshold restored to 0", + now_bpr == 0, "bpr now %s" % now_bpr) + + R.check("Hygiene", "zero net Extended Events sessions after the run", + after_tests == before, + "leaked: %s" % [s for s in after_tests if s not in before]) + R.check("Hygiene", "no sp_HumanEvents sessions remain after sweep", + after_final == before, + "still present: %s" % after_final) + + print() + print("Sessions after tests (pre-final-sweep): %s" + % (after_tests if after_tests else "(none)")) + print("Sessions after final sweep: %s" + % (after_final if after_final else "(none)")) + net_new = [s for s in after_tests if s not in before] + print("Net-new sessions (diff): %s" + % (net_new if net_new else "(none) -> zero net")) + print() + + for r in R.items: + status = "PASS" if r["passed"] else "FAIL" + detail = (" (%s)" % r["detail"]) if (not r["passed"] and r["detail"]) else "" + print(" [%s] %s: %s%s" % (status, r["group"], r["name"], detail)) + + print() + print("Results: %d passed, %d failed, %d total" % (R.passed, R.failed, len(R.items))) + + if R.failed > 0: + print() + print("FAILED TESTS:") + for r in R.items: + if not r["passed"]: + print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"])) + sys.exit(1) + else: + print("All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/sp_IndexCleanup/README.md b/sp_IndexCleanup/README.md index bc63ae0c..288d5204 100644 --- a/sp_IndexCleanup/README.md +++ b/sp_IndexCleanup/README.md @@ -33,6 +33,7 @@ The procedure requires SQL Server 2012 (11.0) or later due to the use of FORMAT | @get_all_databases | bit | 0 | When set to 1, analyzes all eligible databases on the server | | @include_databases | nvarchar(max) | NULL | Comma-separated list of databases to include (used with @get_all_databases = 1) | | @exclude_databases | nvarchar(max) | NULL | Comma-separated list of databases to exclude (used with @get_all_databases = 1) | +| @sort_order | varchar(20) | default | Controls final result ordering: default groups by script type within each database, object groups all rows for the same index together (Key Subset disables sort under their replacement index) | | @help | bit | 0 | Displays help information | | @debug | bit | 0 | Prints debug information during execution | | @version | varchar(20) | NULL | OUTPUT parameter that returns the version number of the procedure | diff --git a/sp_IndexCleanup/sp_IndexCleanup.sql b/sp_IndexCleanup/sp_IndexCleanup.sql index 97087c74..49977943 100644 --- a/sp_IndexCleanup/sp_IndexCleanup.sql +++ b/sp_IndexCleanup/sp_IndexCleanup.sql @@ -55,8 +55,8 @@ ALTER PROCEDURE @database_name sysname = NULL, /*focus on a single database*/ @schema_name sysname = NULL, /*use when focusing on a single table/view, or to a single schema with no table name*/ @table_name sysname = NULL, /*use when focusing on a single table or view*/ - @min_reads bigint = 0, /*only look at indexes with a minimum number of reads*/ - @min_writes bigint = 0, /*only look at indexes with a minimum number of writes*/ + @min_reads bigint = 0, /*only dedupe indexes with a minimum number of reads; an index clears by meeting this or @min_writes*/ + @min_writes bigint = 0, /*only dedupe indexes with a minimum number of writes; an index clears by meeting this or @min_reads*/ @min_size_gb decimal(10,2) = 0, /*only look at indexes with a minimum size*/ @min_rows bigint = 0, /*only look at indexes with a minimum number of rows*/ @dedupe_only bit = 'false', /*only perform deduplication, don't mark unused indexes for removal*/ @@ -75,8 +75,8 @@ BEGIN SET NOCOUNT ON; BEGIN TRY SELECT - @version = '2.7', - @version_date = '20260701'; + @version = '2.8', + @version_date = '20260801'; IF /* Check SQL Server 2012+ for FORMAT and CONCAT functions */ @@ -105,6 +105,29 @@ BEGIN TRY RETURN; END; + /* + Default the bit parameters when a caller passes NULL — an + uninitialized bit variable, most often. Every downstream test is + "= 0" or "= 1", and NULL satisfies neither. + + This has to run HERE, ahead of every consumer, and @dedupe_only is + why. The low-uptime guard below both READS and WRITES it: + + IF @uptime_days <= 7 AND @dedupe_only = 0 SET @dedupe_only = 1; + + With NULL, "@dedupe_only = 0" is UNKNOWN, so that safety guard never + fires — and defaulting the parameter any later means the procedure + goes on to recommend dropping unused indexes based on a few days of + usage stats, with the protection silently skipped. @help and @debug + are consumed just below too, so a late default would be dead code + for them. + */ + SELECT + @get_all_databases = ISNULL(@get_all_databases, 0), + @dedupe_only = ISNULL(@dedupe_only, 0), + @debug = ISNULL(@debug, 0), + @help = ISNULL(@help, 0); + /* Help section, for help. Will become more helpful when out of beta. @@ -143,8 +166,8 @@ BEGIN TRY WHEN N'@database_name' THEN 'the name of the database you wish to analyze' WHEN N'@schema_name' THEN 'limits analysis to tables in the specified schema when used without @table_name' WHEN N'@table_name' THEN 'the table or view name to filter indexes by, requires @schema_name if not dbo' - WHEN N'@min_reads' THEN 'minimum number of reads for an index to be considered used' - WHEN N'@min_writes' THEN 'minimum number of writes for an index to be considered used' + WHEN N'@min_reads' THEN 'minimum reads for an index to be deduped; applied per table AND per index, and an index clears by meeting this or @min_writes. Does not affect unused-index detection or compression' + WHEN N'@min_writes' THEN 'minimum writes for an index to be deduped; applied per table AND per index, and an index clears by meeting this or @min_reads. Does not affect unused-index detection or compression' WHEN N'@min_size_gb' THEN 'minimum size in GB for an index to be analyzed' WHEN N'@min_rows' THEN 'minimum number of rows for a table to be analyzed' WHEN N'@dedupe_only' THEN 'only perform index deduplication, do not mark unused indexes for removal' @@ -697,6 +720,15 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. target_index_name sysname NULL, /* When this is a target, the index which points to it as a supersedes in consolidation */ superseded_by nvarchar(4000) NULL, + /* + Whether this index cleared @min_reads / @min_writes on its own. Defaults + to 1 and only drops to 0 when a caller sets a floor and this index misses + it. The dedupe rules ignore anything marked 0; Rule 1 and compression + analysis deliberately do not look at it, since an index can be too cold to + be worth deduping while still being worth reporting as unused or worth + compressing. + */ + meets_usage_floor bit NOT NULL DEFAULT 1, /* Priority score from 0-1 to determine which index to keep (higher is better) */ index_priority decimal(10,6) NULL, /* Hash columns for optimized matching */ @@ -1079,7 +1111,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. N'' + REPLACE ( - @include_databases, + /* + Escape the XML metacharacters before splicing + the list into a document. A database name is a + delimited identifier, so [Sales & Marketing] is + legal, and an unescaped & < or > made the + concatenated string malformed XML — CONVERT + threw 9411 and killed the whole run. The + ampersand MUST be escaped first: doing < first + would produce <, whose & would then be + escaped again into &lt;. .value() decodes + the entities on the way back out, so the real + name comes through intact. + */ + REPLACE + ( + REPLACE + ( + REPLACE + ( + @include_databases, + N'&', + N'&' + ), + N'<', + N'<' + ), + N'>', + N'>' + ), N',', N'' ) + @@ -1123,7 +1183,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. N'' + REPLACE ( - @exclude_databases, + /* + Escape the XML metacharacters before splicing + the list into a document. A database name is a + delimited identifier, so [Sales & Marketing] is + legal, and an unescaped & < or > made the + concatenated string malformed XML — CONVERT + threw 9411 and killed the whole run. The + ampersand MUST be escaped first: doing < first + would produce <, whose & would then be + escaped again into &lt;. .value() decodes + the entities on the way back out, so the real + name comes through intact. + */ + REPLACE + ( + REPLACE + ( + REPLACE + ( + @exclude_databases, + N'&', + N'&' + ), + N'<', + N'<' + ), + N'>', + N'>' + ), N',', N'' ) + @@ -1214,10 +1302,31 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SET @database_name = DB_NAME(); END; - /* Strip brackets if user supplied them */ - IF @database_name IS NOT NULL + /* + Strip brackets if user supplied them. + + Not PARSENAME: that parses dotted multi-part names, so a real + database called Foo.Bar came back as just "Bar" and matched + nothing (or worse, the wrong database). Strip only genuine + surrounding brackets, and un-escape the doubled ]] that + QUOTENAME would have produced inside them. + */ + IF @database_name IS NOT NULL + AND LEFT(@database_name, 1) = N'[' + AND RIGHT(@database_name, 1) = N']' BEGIN - SET @database_name = PARSENAME(@database_name, 1); + SET @database_name = + REPLACE + ( + SUBSTRING + ( + @database_name, + 2, + LEN(@database_name) - 2 + ), + N']]', + N']' + ); END; /* Single database mode */ @@ -1251,6 +1360,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. RETURN; END; + /* + Resolve the database_id locally with DB_ID() rather than trusting + sys.databases. On Azure SQL DB, sys.databases.database_id is scoped + to the logical server while DB_ID(), DB_NAME(), and the database_id + column in every DMV are scoped to the database/elastic pool, and the + two id spaces do not have to agree. Carrying the sys.databases id + made DB_NAME(@database_id) return NULL (Msg 515 on NOT NULL + database_name columns) and made DMV database_id joins match nothing. + DB_ID(d.name) returns the same value as d.database_id everywhere + except Azure, where it returns the id the DMVs actually use. + */ INSERT INTO #databases WITH @@ -1261,7 +1381,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ) SELECT d.name, - d.database_id + database_id = + ISNULL + ( + DB_ID(d.name), + d.database_id + ) FROM sys.databases AS d WHERE d.name = @database_name AND d.state = 0 @@ -1295,7 +1420,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ) SELECT d.name, - d.database_id + /* DB_ID(d.name) rather than d.database_id: see the single-database insert above */ + database_id = + ISNULL + ( + DB_ID(d.name), + d.database_id + ) FROM sys.databases AS d WHERE d.database_id > 4 /* Skip system databases */ AND d.state = 0 @@ -1501,6 +1632,32 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. RAISERROR('Generating #filtered_object insert', 0, 0) WITH NOWAIT; END; + /* + One pass per database. Read this before touching anything inside the loop. + + #index_analysis is NOT truncated between iterations - it accumulates every + database's rows because the final result set is built from it after the + cursor finishes. Every OTHER working temp table (#index_details, + #partition_stats, #compression_eligibility, #merged_includes and the rest, + reset just below) holds only the CURRENT database. + + So any statement in this loop that reads or writes #index_analysis must + filter to ia.database_id = @current_database_id. Without it the statement + also processes rows for databases already handled, whose context tables have + since been truncated - and that produced silent, executable, WRONG DDL: + merge scripts stripped of their INCLUDE lists (the recompute read an empty + #index_details and returned NULL) and ALTER INDEX ... DISABLE against unique + constraints (a NOT EXISTS guard passed vacuously against the empty table). + Both ship a script that runs cleanly and quietly destroys index coverage or + referential integrity. + + The scope is free in single-database runs and semantically free in general: + scope_hash already encodes database plus object and every rule joins on it, + so no rule can legitimately pair rows across databases anyway. For joins + between two #index_analysis aliases, scoping the driven side is enough - the + join carries the partner along. Statements that intentionally roll up across + ALL databases live AFTER the loop and are not scoped. + */ WHILE @@FETCH_STATUS = 0 BEGIN /*Truncate temp tables between database iterations*/ @@ -1582,7 +1739,11 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SELECT DISTINCT @database_id, - database_name = DB_NAME(@database_id), + /* + @database_name, never DB_NAME(@database_id): on Azure SQL DB the id + spaces differ and DB_NAME() of a foreign-space id returns NULL + */ + database_name = @database_name, schema_id = s.schema_id, schema_name = s.name, object_id = i.object_id, @@ -1689,6 +1850,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. to analyze every index, including never-used ones (the whole point of the tool). @min_reads and @min_writes are validated to non-NULL, non-negative above. */ + /* + Each term is gated on its own threshold being set. Without that gate, + a caller who set only @min_reads still got SUM(user_updates) >= 0 as + the other half of the OR - always true - so the whole filter did + nothing. The OR itself is intended: an index counts as used if it + clears EITHER floor. + */ IF @min_reads > 0 OR @min_writes > 0 BEGIN @@ -1708,9 +1876,15 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. GROUP BY ius.object_id HAVING - SUM(ius.user_seeks + ius.user_scans + ius.user_lookups) >= @min_reads + ( + @min_reads > 0 + AND SUM(ius.user_seeks + ius.user_scans + ius.user_lookups) >= @min_reads + ) OR - SUM(ius.user_updates) >= @min_writes + ( + @min_writes > 0 + AND SUM(ius.user_updates) >= @min_writes + ) )'; END; @@ -1741,6 +1915,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. EXECUTE sys.sp_executesql @sql, N'@database_id integer, + @database_name sysname, @min_reads bigint, @min_writes bigint, @min_size_gb decimal(10,2), @@ -1748,6 +1923,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. @object_id integer, @schema_name sysname', @current_database_id, + @current_database_name, @min_reads, @min_writes, @min_size_gb, @@ -2150,7 +2326,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SELECT os.database_id, - database_name = DB_NAME(os.database_id), + database_name = @database_name, schema_id = s.schema_id, schema_name = s.name, os.object_id, @@ -2211,7 +2387,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ) GROUP BY os.database_id, - DB_NAME(os.database_id), s.schema_id, s.name, os.object_id, @@ -2273,8 +2448,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. EXECUTE sys.sp_executesql @sql, N'@database_id integer, + @database_name sysname, @object_id integer', @current_database_id, + @current_database_name, @object_id; SET @rc = ROWCOUNT_BIG(); @@ -2358,13 +2535,50 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND fo.object_id = us.object_id ); + /* + The @min_rows filter in this batch SUMs row_count across partitions, + matching the object pre-filter. Comparing each partition row on its + own meant a partitioned table whose TOTAL rows cleared @min_rows was + still analyzed by the pre-filter but then dropped here unless some + single partition cleared the floor alone - the two filters disagreed + about what "@min_rows" means. + + On is_eligible_for_dedupe below: the CASE tests i.type = 1 OR + i.is_primary_key = 1 FIRST, and that order is load bearing. A + nonclustered primary key is type = 2, so testing type = 2 first matched + it as an ordinary nonclustered index and the is_primary_key branch could + never be reached for the one kind of index it was written for. That let + primary keys into the dedupe rules, which check only + is_unique_constraint - 0 for a primary key. Rule 5 then emitted + CREATE UNIQUE INDEX ... WITH (DROP_EXISTING = ON) against the PK + (Msg 1907, while the paired DISABLE of the other index still ran), and + Rule 2 could emit ALTER INDEX ... DISABLE against it, which does not + error: it succeeds, and silently disables every inbound foreign key + along with it. + + Primary keys are ineligible for dedupe. UNIQUE constraints deliberately + are NOT: they stay eligible because Rule 7 and Rule 7.5 exist to replace + a unique constraint with an equivalent nonclustered index, and they need + the row to do it. A primary key has no such replacement path - nothing + should ever drop one - so it is excluded here instead. + + Excluding a PK does not cost it its compression recommendation. The + insert that backfills #index_analysis for compression already selects + WHERE fo.index_id = 1 OR id.is_primary_key = 1, and already renders the + definition as ALTER TABLE ... ADD CONSTRAINT ... PRIMARY KEY + NONCLUSTERED. That code was unreachable until this CASE was fixed. + + Keep prose out of the string literal below. Everything up to the first + CONVERT(nvarchar(max), ...) concatenates as nvarchar(4000), so padding + it silently truncates the batch mid-statement instead of failing loudly. + */ SELECT @sql = N' SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT database_id = @database_id, - database_name = DB_NAME(@database_id), + database_name = @database_name, i.object_id, i.index_id, s.schema_id, @@ -2445,14 +2659,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. us.last_user_update, is_eligible_for_dedupe = CASE - WHEN i.type = 2 - THEN 1 WHEN ( i.type = 1 OR i.is_primary_key = 1 ) THEN 0 + WHEN i.type = 2 + THEN 1 END FROM ' + QUOTENAME(@current_database_name) + N'.sys.indexes AS i JOIN ' + QUOTENAME(@current_database_name) + N'.sys.objects AS o @@ -2497,7 +2711,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. N'.sys.dm_db_partition_stats ps WHERE ps.object_id = i.object_id AND ps.index_id IN (0, 1) - AND ps.row_count >= @min_rows + GROUP BY + ps.object_id + HAVING + SUM(ps.row_count) >= @min_rows )' ); @@ -2577,9 +2794,11 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. EXECUTE sys.sp_executesql @sql, N'@database_id integer, + @database_name sysname, @object_id integer, @min_rows bigint', @current_database_id, + @current_database_name, @object_id, @min_rows; @@ -2609,7 +2828,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SELECT database_id = @database_id, - database_name = DB_NAME(@database_id), + database_name = @database_name, x.object_id, x.index_id, x.schema_id, @@ -2723,7 +2942,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ( SELECT N'', '' + - c.name + QUOTENAME(c.name) FROM ' + QUOTENAME(@current_database_name) + N'.sys.index_columns AS ic JOIN ' + QUOTENAME(@current_database_name) + N'.sys.columns AS c ON c.object_id = ic.object_id @@ -2777,8 +2996,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. EXECUTE sys.sp_executesql @sql, N'@database_id integer, + @database_name sysname, @object_id integer', @current_database_id, + @current_database_name, @object_id; SET @rc = ROWCOUNT_BIG(); @@ -2823,7 +3044,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ) SELECT @current_database_id, - database_name = DB_NAME(@current_database_id), + database_name = @current_database_name, id1.schema_id, id1.schema_name, id1.table_name, @@ -2847,6 +3068,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id2.object_id = id1.object_id AND id2.index_id = id1.index_id AND id2.is_included_column = 0 + /* + A partitioned index's partitioning column rides along at + key_ordinal = 0 when it is not a real key. Without this it was + emitted as a phantom LEADING key, corrupting rebuilt indexes + and poisoning duplicate/subset hashing. + */ + AND id2.key_ordinal > 0 GROUP BY id2.column_name, id2.is_descending_key, @@ -2894,7 +3122,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHEN id1.is_unique_constraint = 1 THEN N'ALTER TABLE ' + - QUOTENAME(DB_NAME(@current_database_id)) + + QUOTENAME(@current_database_name) + N'.' + QUOTENAME(id1.schema_name) + N'.' + @@ -2920,7 +3148,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. N'INDEX ' + QUOTENAME(id1.index_name) + N' ON ' + - QUOTENAME(DB_NAME(@current_database_id)) + + QUOTENAME(@current_database_name) + N'.' + QUOTENAME(id1.schema_name) + N'.' + @@ -2942,6 +3170,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id2.object_id = id1.object_id AND id2.index_id = id1.index_id AND id2.is_included_column = 0 + /* + A partitioned index's partitioning column rides along at + key_ordinal = 0 when it is not a real key. Without this it was + emitted as a phantom LEADING key, corrupting rebuilt indexes + and poisoning duplicate/subset hashing. + */ + AND id2.key_ordinal > 0 GROUP BY id2.column_name, id2.is_descending_key, @@ -3249,6 +3484,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THEN 50 /* Indexes with includes get priority over those without */ ELSE 0 END /* Prefer indexes with included columns */ + WHERE #index_analysis.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -3288,6 +3524,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id.is_eligible_for_dedupe = 1 /* Only eligible indexes */ ) AND #index_analysis.index_id <> 1 /* Don't disable clustered indexes */ + AND #index_analysis.database_id = @current_database_id OPTION(RECOMPILE); END; @@ -3300,6 +3537,73 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. OPTION(RECOMPILE); END; + /* + Apply @min_reads / @min_writes per index, not just per object. + + The filter that built #filtered_objects only asks whether a TABLE clears the + floors: it groups sys.dm_db_index_usage_stats by object_id, summing usage + across every index on the object. A table busy enough to qualify dragged in + all of its indexes with it, including ones nowhere near the floor the caller + asked for, which is not what '@min_reads: only look at indexes with a + minimum number of reads' promises. + + This marks rather than deletes, and it sits between Rule 1 and the dedupe + rules. Both of those are deliberate: + + - Marking keeps the row. Compression analysis reaches nonclustered indexes + through #index_analysis, and compression eligibility has nothing to do + with usage: an index can be far too cold to bother deduping and still be + worth compressing. Deleting the row would take its compression + recommendation with it. + - Rule 1 (unused indexes) has already run, and it does not look at this + flag, so a never-read index is still reported as unused. Screening ahead + of Rule 1 would make it unreachable: Rule 1's predicate is reads = 0, + which is a strict subset of everything a reads floor removes, so + @min_reads alone would silently switch unused-index detection off. + + An index clears the screen if it meets EITHER floor, matching the object + filter's OR, with each term gated on its own threshold being set so that + passing only @min_reads doesn't leave user_updates >= 0 as an always-true + escape hatch on the write side. + */ + IF @min_reads > 0 + OR @min_writes > 0 + BEGIN + UPDATE + ia + SET + ia.meets_usage_floor = 0 + FROM #index_analysis AS ia + WHERE EXISTS + ( + SELECT + 1/0 + FROM #index_details AS id + WHERE id.index_hash = ia.index_hash + AND NOT + ( + ( + @min_reads > 0 + AND (id.user_seeks + id.user_scans + id.user_lookups) >= @min_reads + ) + OR + ( + @min_writes > 0 + AND id.user_updates >= @min_writes + ) + ) + ) + AND ia.database_id = @current_database_id + OPTION(RECOMPILE); + + SET @rc = ROWCOUNT_BIG(); + + IF @debug = 1 + BEGIN + RAISERROR('Index-level reads/writes filter excluded %I64d indexes from dedupe analysis', 0, 0, @rc) WITH NOWAIT; + END; + END; + /* Rule 2: Exact duplicates - matching key columns and includes */ UPDATE ia1 @@ -3330,6 +3634,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia1.exact_match_hash = ia2.exact_match_hash /* Exact match: keys + includes + filter */ WHERE ia1.consolidation_rule IS NULL /* Not already processed */ AND ia2.consolidation_rule IS NULL /* Not already processed */ + /* Both sides must have cleared @min_reads / @min_writes on their own */ + AND ia1.meets_usage_floor = 1 + AND ia2.meets_usage_floor = 1 /* Exclude unique constraints - we'll handle those separately in Rule 7 */ AND NOT EXISTS ( @@ -3375,6 +3682,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id1.is_descending_key <> id2.is_descending_key /* Different sort direction */ ) + AND ia1.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -3410,6 +3718,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id2_inner.index_hash = ia2_inner.index_hash AND id2_inner.is_eligible_for_dedupe = 1 ) + /* Same constraint exclusion as the outer join below: a + unique constraint cannot serve as the merge target. */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #index_details AS id2_uc_inner + WHERE id2_uc_inner.index_hash = ia2_inner.index_hash + AND id2_uc_inner.is_unique_constraint = 1 + ) AND NOT EXISTS ( SELECT @@ -3439,7 +3757,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia1.is_unique = 0 WHERE ia1.consolidation_rule IS NULL /* Not already processed */ AND ia2.consolidation_rule IS NULL /* Not already processed */ - /* Don't disable unique constraints — but allow them as the wider (target) index */ + /* Both sides must have cleared @min_reads / @min_writes on their own */ + AND ia1.meets_usage_floor = 1 + AND ia2.meets_usage_floor = 1 + /* Don't disable unique constraints */ AND NOT EXISTS ( SELECT @@ -3448,6 +3769,23 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id1_uc.index_hash = ia1.index_hash AND id1_uc.is_unique_constraint = 1 ) + /* + And don't pick one as the wider TARGET either. Merging a subset's + includes into a constraint-backed index emits CREATE INDEX ... WITH + (DROP_EXISTING = ON) against the constraint's index, which SQL + Server refuses outright (Msg 1907) — while the paired DISABLE of + the subset still runs, silently losing a covering index the + constraint never absorbed. Passing on the dedupe entirely is the + safe trade: the subset survives, the constraint stays untouched. + */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #index_details AS id2_uc + WHERE id2_uc.index_hash = ia2.index_hash + AND id2_uc.is_unique_constraint = 1 + ) AND EXISTS ( SELECT @@ -3475,6 +3813,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id2.index_hash = ia2.index_hash /* Specific index from ia2 */ AND id1.is_descending_key <> id2.is_descending_key /* Different sort direction */ ) + AND ia1.database_id = @current_database_id OPTION(RECOMPILE); DECLARE @rule3_rowcount bigint = ROWCOUNT_BIG(); @@ -3510,7 +3849,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia2.consolidation_rule = N'Key Subset' AND ia2.action = N'DISABLE' AND ia2.target_index_name IS NOT NULL - AND ia1.target_index_name <> ia2.target_index_name; + AND ia1.target_index_name <> ia2.target_index_name + AND ia1.database_id = @current_database_id; SET @chains_resolved = ROWCOUNT_BIG(); END; @@ -3549,6 +3889,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE ia1.consolidation_rule = N'Key Subset' AND ia1.action = N'DISABLE' AND ia2.consolidation_rule IS NULL /* Not already processed */ + AND ia2.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -3612,6 +3953,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ISNULL(ia1.included_columns, '') <> ISNULL(ia2.included_columns, '') /* Different includes */ WHERE ia1.consolidation_rule IS NULL /* Not already processed */ AND ia2.consolidation_rule IS NULL /* Not already processed */ + /* Both sides must have cleared @min_reads / @min_writes on their own */ + AND ia1.meets_usage_floor = 1 + AND ia2.meets_usage_floor = 1 /* Exclude pairs where either one is a unique constraint (we'll handle those separately in Rule 7) */ AND NOT EXISTS ( @@ -3645,6 +3989,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id2.index_hash = ia2.index_hash AND id2.is_eligible_for_dedupe = 1 ) + AND ia1.database_id = @current_database_id OPTION(RECOMPILE); DECLARE @rule5_rowcount bigint = ROWCOUNT_BIG(); @@ -3695,52 +4040,67 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. superset.scope_hash, superset.index_name, superset.key_columns, + /* + Merge the superset's own includes with those of every subset it + supersedes. + + This reads #index_details, one row per column, instead of splitting the + included_columns strings on ', '. A column name may legally contain a + comma and a space, so that delimiter could tear [Last, First] into two + fragments and the DISTINCT sort could then slot another column between + them, emitting a broken INCLUDE list while the paired DISABLE still ran. + Reading the columns directly also removes the need to escape entities for + the XML round-trip. + */ merged_includes = STUFF ( ( - SELECT DISTINCT + SELECT N', ' + - t.c.value('.', 'sysname') - FROM + QUOTENAME(idc.column_name) + FROM #index_details AS idc + WHERE idc.is_included_column = 1 + AND + ( + /* The superset's own includes */ + idc.index_hash = superset.index_hash + /* Plus those of every subset it supersedes */ + OR EXISTS + ( + SELECT + 1/0 + FROM #index_analysis AS subset + WHERE subset.scope_hash = superset.scope_hash + AND subset.target_index_name = superset.index_name + AND subset.action = N'DISABLE' + AND subset.consolidation_rule = N'Key Subset' + AND subset.index_hash = idc.index_hash + ) + ) + /* + A column already in the superset's key doesn't need + including. Matching column names exactly beats searching the + key_columns string for one. + */ + AND NOT EXISTS ( - /* Superset's own includes */ - SELECT - x = CONVERT - ( - xml, - N'' + - REPLACE(superset.included_columns, N', ', N'') + - N'' - ) - WHERE superset.included_columns IS NOT NULL - - UNION ALL - - /* ALL subsets' includes */ SELECT - x = CONVERT - ( - xml, - N'' + - REPLACE(subset.included_columns, N', ', N'') + - N'' - ) - FROM #index_analysis AS subset - WHERE subset.scope_hash = superset.scope_hash - AND subset.target_index_name = superset.index_name - AND subset.action = N'DISABLE' - AND subset.consolidation_rule = N'Key Subset' - AND subset.included_columns IS NOT NULL - ) AS a - CROSS APPLY a.x.nodes('/c') AS t(c) - /* Filter out columns already in superset's key */ - WHERE CHARINDEX(t.c.value('.', 'sysname'), superset.key_columns) = 0 - AND LEN(t.c.value('.', 'sysname')) > 0 + 1/0 + FROM #index_details AS idk + WHERE idk.index_hash = superset.index_hash + AND idk.is_included_column = 0 + AND idk.column_name = idc.column_name + ) + GROUP BY + idc.column_name + ORDER BY + idc.column_name FOR XML - PATH('') - ), + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), 1, 2, '' @@ -3748,6 +4108,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. FROM #index_analysis AS superset WHERE superset.action = N'MERGE INCLUDES' AND superset.consolidation_rule = N'Key Superset' + AND superset.database_id = @current_database_id OPTION(RECOMPILE); /* Apply the pre-computed merged includes */ @@ -3759,6 +4120,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. JOIN #merged_includes AS mi ON mi.scope_hash = ia.scope_hash AND mi.index_name = ia.index_name + WHERE ia.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -3813,6 +4175,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia1_check.consolidation_rule = N'Key Subset' AND ia1_check.action = N'DISABLE' ) + AND ia2.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -3838,6 +4201,33 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. FROM #index_analysis AS ia1 WHERE ia1.consolidation_rule IS NULL /* Not already processed */ AND ia1.action IS NULL /* Not already processed by earlier rules */ + /* Must have cleared @min_reads / @min_writes on its own */ + AND ia1.meets_usage_floor = 1 + /* + A filtered index can never stand in for a unique constraint: the + constraint enforces uniqueness over EVERY row, the filtered index + only over rows matching its predicate. Promoting one and dropping + the constraint silently un-enforced uniqueness outside the filter. + */ + AND ia1.filter_definition IS NULL + /* + A partitioned index can only be made UNIQUE if its partitioning + column is part of its key — SQL Server rejects anything else with + Msg 1908. The key list correctly excludes a partition column that is + not a real key, but the ON clause still partitions by it, so + promoting such an index emits a CREATE UNIQUE that can never run + while the paired DROP CONSTRAINT runs fine: the constraint goes and + nothing replaces it. Pass on the recommendation entirely. + */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #partition_stats AS ps_uq + WHERE ps_uq.index_hash = ia1.index_hash + AND ps_uq.partition_columns IS NOT NULL + AND CHARINDEX(ps_uq.partition_columns, ia1.key_columns) = 0 + ) AND EXISTS ( /* Find nonclustered indexes */ @@ -3895,6 +4285,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id2_inner.is_included_column = 0 ) ) + AND ia1.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -3922,6 +4313,20 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ON ia_nc.scope_hash = ia_uc.scope_hash /* Same database and object */ AND ia_nc.index_name <> ia_uc.index_name /* Different index */ AND ia_uc.key_columns = ia_nc.key_columns /* Verify key columns EXACT match */ + /* A filtered index only enforces uniqueness inside its predicate; + it can never replace a full-table unique constraint. */ + AND ia_nc.filter_definition IS NULL + /* Same Msg 1908 guard as Rule 7: a partitioned index whose + partition column is not in its key cannot be made unique. */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #partition_stats AS ps_uq + WHERE ps_uq.index_hash = ia_nc.index_hash + AND ps_uq.partition_columns IS NOT NULL + AND CHARINDEX(ps_uq.partition_columns, ia_nc.key_columns) = 0 + ) WHERE NOT EXISTS ( /* Don't propose replacing a unique constraint that backs an inbound @@ -3950,6 +4355,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id_nc.index_hash = ia_nc.index_hash AND id_nc.is_unique_constraint = 1 ) + AND ia_uc.database_id = @current_database_id OPTION(RECOMPILE); /* Second, mark nonclustered indexes to be made unique */ @@ -3964,23 +4370,35 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. JOIN #index_details AS id_nc /* Join to get nonclustered index details */ ON id_nc.index_hash = ia_nc.index_hash AND id_nc.is_unique_constraint = 0 /* This is not a unique constraint */ - WHERE - /* Two conditions for matching: - 1. Index key columns exactly match a unique constraint's key columns - 2. A unique constraint is already marked for DISABLE and has this index as target */ - EXISTS - ( - /* Find unique constraint with matching keys that should be disabled */ - SELECT - 1/0 - FROM #index_analysis AS ia_uc - JOIN #index_details AS id_uc - ON id_uc.index_hash = ia_uc.index_hash - AND id_uc.is_unique_constraint = 1 - WHERE ia_uc.scope_hash = ia_nc.scope_hash - /* Check that both indexes have EXACTLY the same key columns */ - AND ia_uc.key_columns = ia_nc.key_columns - ) + /* Same filtered-index guard as the DISABLE marking above. */ + WHERE ia_nc.filter_definition IS NULL + /* Same Msg 1908 guard as the DISABLE marking above. */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #partition_stats AS ps_uq + WHERE ps_uq.index_hash = ia_nc.index_hash + AND ps_uq.partition_columns IS NOT NULL + AND CHARINDEX(ps_uq.partition_columns, ia_nc.key_columns) = 0 + ) + /* Two conditions for matching: + 1. Index key columns exactly match a unique constraint's key columns + 2. A unique constraint is already marked for DISABLE and has this index as target */ + AND EXISTS + ( + /* Find unique constraint with matching keys that should be disabled */ + SELECT + 1/0 + FROM #index_analysis AS ia_uc + JOIN #index_details AS id_uc + ON id_uc.index_hash = ia_uc.index_hash + AND id_uc.is_unique_constraint = 1 + WHERE ia_uc.scope_hash = ia_nc.scope_hash + /* Check that both indexes have EXACTLY the same key columns */ + AND ia_uc.key_columns = ia_nc.key_columns + ) + AND ia_nc.database_id = @current_database_id OPTION(RECOMPILE); /* CRITICAL: Ensure that only the unique constraints that exactly match get this treatment */ @@ -4004,6 +4422,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia_uc.action = N'DISABLE' AND ia_uc.target_index_name = ia.index_name ) + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Make sure the nonclustered index has the superseded_by field set correctly */ @@ -4024,6 +4443,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia_uc.action = N'DISABLE' AND ia_uc.target_index_name = ia_nc.index_name WHERE ia_nc.action = N'MAKE UNIQUE' + AND ia_nc.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -4129,6 +4549,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id_fk.is_foreign_key_reference = 1 AND id_fk.is_included_column = 0 ) + AND ia_loser.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -4167,64 +4588,122 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id_uc.index_hash = ia_dup.index_hash AND id_uc.is_unique_constraint = 1 ) + /* + Both sides must be present in #index_details and eligible for dedupe, the + same check Rules 2, 3, 5 and 7 make: a row still in #index_analysis whose + index is a primary key or is otherwise ineligible must not be picked up here. + Cross-database staleness is handled by the @current_database_id scope below, + not by this predicate. + */ + AND EXISTS + ( + SELECT + 1/0 + FROM #index_details AS id_dup + WHERE id_dup.index_hash = ia_dup.index_hash + AND id_dup.is_eligible_for_dedupe = 1 + ) + AND EXISTS + ( + SELECT + 1/0 + FROM #index_details AS id_win + WHERE id_win.index_hash = ia_winner.index_hash + AND id_win.is_eligible_for_dedupe = 1 + ) + /* + Only rows for the database being processed. #index_analysis accumulates + across the whole cursor for the final output, so without this every rule + re-runs over already-processed databases whose per-database context tables + (#index_details and the rest) have since been truncated - which produced + silent, wrong DDL. See the note at the top of the cursor loop. + */ + AND ia_dup.database_id = @current_database_id OPTION(RECOMPILE); /* Merge includes from the disabled Key Duplicates into the MAKE UNIQUE index */ UPDATE ia_winner SET + /* + Merge the includes of every index being disabled in this winner's favor + into the index being made unique. + + Sourced from #index_details, one row per column, rather than splitting + included_columns on ', '. A column name may legally contain a comma and + a space, which made that delimiter ambiguous: [Last, First] tore into two + fragments the DISTINCT sort could separate, producing a broken INCLUDE + list on a script whose paired DISABLE still ran. + + Both loser rules matter, and missing one is not a cosmetic error. Rule 7.5 + can rewrite a row Rule 6 already processed as a Key Superset into MAKE + UNIQUE, so a winner arriving here may own Key SUBSET losers as well as Key + DUPLICATE ones. Reading only Key Duplicates discarded the subsets' + includes that Rule 6 had already merged in, while their DISABLE still + ran - a covering column silently gone, on a script that executes cleanly. + The old string-splitting code survived this by accident: it read the + accumulated included_columns, so an earlier merge carried forward. + Re-deriving from disk does not, which is why every loser has to be + gathered here explicitly. + + Key duplicates share the winner's keys, but key subsets do not, so losers + are matched on target_index_name rather than key_filter_hash, and a column + already in the winner's key is excluded - SQL Server rejects an index that + includes its own key column (Msg 1909). + */ ia_winner.included_columns = - ( - SELECT - combined_cols = - STUFF + STUFF + ( + ( + SELECT + N', ' + + QUOTENAME(idc.column_name) + FROM #index_details AS idc + WHERE idc.is_included_column = 1 + AND ( - ( - SELECT DISTINCT - N', ' + t.c.value('.', 'sysname') - FROM - ( - /* Winner's includes */ - SELECT - x = CONVERT - ( - xml, - N'' + - REPLACE(ISNULL(ia_winner.included_columns, N''), N', ', N'') + - N'' - ) - WHERE ia_winner.included_columns IS NOT NULL - - UNION ALL - - /* Loser's includes */ - SELECT - x = CONVERT - ( - xml, - N'' + - REPLACE(ia_loser.included_columns, N', ', N'') + - N'' - ) - FROM #index_analysis AS ia_loser - WHERE ia_loser.scope_hash = ia_winner.scope_hash - AND ia_loser.key_filter_hash = ia_winner.key_filter_hash - AND ia_loser.action = N'DISABLE' - AND ia_loser.consolidation_rule = N'Key Duplicate' - AND ia_loser.target_index_name = ia_winner.index_name - AND ia_loser.included_columns IS NOT NULL - ) AS a - CROSS APPLY a.x.nodes('/c') AS t(c) - WHERE LEN(t.c.value('.', 'sysname')) > 0 - FOR - XML - PATH('') - ), - 1, - 2, - '' + /* The winner's own includes */ + idc.index_hash = ia_winner.index_hash + /* Plus those of every index being disabled in its favor */ + OR EXISTS + ( + SELECT + 1/0 + FROM #index_analysis AS ia_loser + WHERE ia_loser.scope_hash = ia_winner.scope_hash + AND ia_loser.action = N'DISABLE' + AND ia_loser.target_index_name = ia_winner.index_name + AND ia_loser.consolidation_rule IN + ( + N'Key Duplicate', + N'Key Subset' + ) + AND ia_loser.index_hash = idc.index_hash + ) ) - ), + /* A column already in the winner's key doesn't need including */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #index_details AS idk + WHERE idk.index_hash = ia_winner.index_hash + AND idk.is_included_column = 0 + AND idk.column_name = idc.column_name + ) + GROUP BY + idc.column_name + ORDER BY + idc.column_name + FOR + XML + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), + 1, + 2, + '' + ), ia_winner.superseded_by = CASE WHEN ia_winner.superseded_by IS NULL @@ -4240,10 +4719,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia_loser.action = N'DISABLE' AND ia_loser.consolidation_rule = N'Key Duplicate' AND ia_loser.target_index_name = ia_winner.index_name + /* TYPE plus .value() so an index named [a&b] isn't reported as [a&b] */ FOR XML - PATH('') - ), + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), 1, 2, '' @@ -4261,10 +4742,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia_loser.action = N'DISABLE' AND ia_loser.consolidation_rule = N'Key Duplicate' AND ia_loser.target_index_name = ia_winner.index_name + /* TYPE plus .value() so an index named [a&b] isn't reported as [a&b] */ FOR XML - PATH('') - ), + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), 1, 2, '' @@ -4284,6 +4767,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia_loser.consolidation_rule = N'Key Duplicate' AND ia_loser.target_index_name = ia_winner.index_name ) + AND ia_winner.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -4310,6 +4794,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia1.index_name < ia2.index_name /* Only process each pair once */ AND ia1.consolidation_rule IS NULL /* Not already processed */ AND ia2.consolidation_rule IS NULL /* Not already processed */ + /* Both sides must have cleared @min_reads / @min_writes on their own */ + AND ia1.meets_usage_floor = 1 + AND ia2.meets_usage_floor = 1 WHERE /* Leading columns match */ EXISTS @@ -4359,6 +4846,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id1.index_hash = ia1.index_hash AND id2.index_hash = ia2.index_hash ) + AND ia1.database_id = @current_database_id OPTION(RECOMPILE); IF @debug = 1 @@ -4562,6 +5050,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. FROM #index_analysis AS ia WHERE ia.action = N'MERGE INCLUDES' AND ia.consolidation_rule = N'Key Duplicate' + AND ia.database_id = @current_database_id GROUP BY ia.database_id, ia.object_id, @@ -4592,6 +5081,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE ia.index_name <> kdd.winning_index_name AND ia.action = N'MERGE INCLUDES' AND ia.consolidation_rule = N'Key Duplicate' + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Update the winning index's superseded_by to list all other indexes */ @@ -4610,6 +5100,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ON ia.scope_hash = kdd.scope_hash AND ia.key_filter_hash = kdd.key_filter_hash WHERE ia.index_name = kdd.winning_index_name + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Merge all included columns from Key Duplicate indexes into the winning index */ @@ -4618,88 +5109,64 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. RAISERROR('Merging included columns from Key Duplicate indexes', 0, 0) WITH NOWAIT; END; - WITH - KeyDuplicateIncludes AS - ( - SELECT - winner.database_id, - winner.object_id, - winner.index_id, - winner.index_name, - winner.index_hash, - winner.included_columns AS winner_includes, - loser.included_columns AS loser_includes - FROM #index_analysis AS winner - JOIN #key_duplicate_dedupe AS kdd - ON winner.scope_hash = kdd.scope_hash - AND winner.key_filter_hash = kdd.key_filter_hash - AND winner.index_name = kdd.winning_index_name - JOIN #index_analysis AS loser - ON loser.scope_hash = kdd.scope_hash - AND loser.key_filter_hash = kdd.key_filter_hash - AND loser.index_name <> kdd.winning_index_name - AND loser.action = N'DISABLE' - AND loser.consolidation_rule = N'Key Duplicate' - WHERE winner.action = N'MERGE INCLUDES' - AND winner.consolidation_rule = N'Key Duplicate' - ) + /* + Merge every disabled duplicate's includes into the winner. + + This reads #index_details rather than splitting the winner's and losers' + included_columns strings apart on ', '. That delimiter is ambiguous: a column + name is allowed to contain a comma and a space, so [Last, First] used to tear + into [Last and First], and the DISTINCT sort could then slot another column + between the halves - emitting INCLUDE ([col_b], [Last, [zzz], First]), which + fails with Msg 102 while the paired DISABLE of the loser still runs. + + #index_details already holds one row per column, so there is nothing to parse + and nothing to re-escape. Winner and losers share key_filter_hash, meaning + identical keys, and SQL Server will not let a key column also be an include, + so no key-column exclusion is needed here. + */ UPDATE ia SET ia.included_columns = - ( - SELECT - /* Combine all includes from winner and all losers, removing duplicates */ - combined_cols = - STUFF + STUFF + ( + ( + SELECT + N', ' + + QUOTENAME(idc.column_name) + FROM #index_details AS idc + WHERE idc.is_included_column = 1 + AND ( - ( - SELECT DISTINCT - N', ' + - t.c.value('.', 'sysname') - FROM - ( - /* Create XML from winner's includes */ - SELECT - x = CONVERT - ( - xml, - N'' + - REPLACE(ISNULL(kdi.winner_includes, N''), N', ', N'') + - N'' - ) - FROM KeyDuplicateIncludes AS kdi - WHERE kdi.index_hash = ia.index_hash - AND kdi.winner_includes IS NOT NULL - - UNION ALL - - /* Create XML from each loser's includes */ - SELECT - x = CONVERT - ( - xml, - N'' + - REPLACE(kdi.loser_includes, N', ', N'') + - N'' - ) - FROM KeyDuplicateIncludes AS kdi - WHERE kdi.index_hash = ia.index_hash - AND kdi.loser_includes IS NOT NULL - ) AS a - /* Split XML into individual columns */ - CROSS APPLY a.x.nodes('/c') AS t(c) - /* Filter out empty strings that can result from NULL handling */ - WHERE LEN(t.c.value('.', 'sysname')) > 0 - FOR - XML - PATH('') - ), - 1, - 2, - '' + /* The winner's own includes */ + idc.index_hash = ia.index_hash + /* Plus every duplicate it is about to disable */ + OR EXISTS + ( + SELECT + 1/0 + FROM #index_analysis AS loser + WHERE loser.scope_hash = ia.scope_hash + AND loser.key_filter_hash = ia.key_filter_hash + AND loser.index_name <> ia.index_name + AND loser.action = N'DISABLE' + AND loser.consolidation_rule = N'Key Duplicate' + AND loser.index_hash = idc.index_hash + ) ) - ) + GROUP BY + idc.column_name + ORDER BY + idc.column_name + FOR + XML + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), + 1, + 2, + N'' + ) FROM #index_analysis AS ia WHERE ia.action = N'MERGE INCLUDES' AND ia.consolidation_rule = N'Key Duplicate' @@ -4711,6 +5178,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE kdd.scope_hash = ia.scope_hash AND kdd.winning_index_name = ia.index_name ) + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Insert Key Duplicate winners into #merged_includes so they get MERGE scripts */ @@ -4753,8 +5221,151 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND loser.included_columns IS NOT NULL AND ISNULL(loser.included_columns, N'') <> ISNULL(ia.included_columns, N'') ) + AND ia.database_id = @current_database_id + OPTION(RECOMPILE); + + /* + Rule 5 settles a unique-vs-non-unique key duplicate in a single pass: the + unique index is marked MERGE INCLUDES and the non-unique loser is marked + DISABLE immediately. That leaves the group holding exactly one MERGE INCLUDES + row, and #key_duplicate_dedupe above only collects groups holding more than + one (HAVING COUNT_BIG(*) > 1), so nothing ever merged the loser's includes + into the winner. The winner was then downgraded to KEEP further down for + having no merged includes, while the loser's DISABLE still ran: any include + the winner did not already carry was silently dropped. + + Rule 5 excludes unique constraints on both sides, so a winner reaching here + is always a plain unique index. Unlike a constraint-backed index (Msg 1907), + that accepts CREATE UNIQUE INDEX ... INCLUDE (...) WITH (DROP_EXISTING = ON), + so the merge is safe to emit. + + Includes come from #index_details, one row per column, rather than from + splitting included_columns on ', ' - a column name may legally contain a + comma and a space, which made that delimiter ambiguous. + + Only rows whose merged list actually differs from what the winner already + covers are recorded. When a loser's includes are a subset of the winner's, + the existing KEEP plus the loser's DISABLE is already correct, and emitting a + rebuild that changes nothing would be noise. + */ + INSERT INTO + #merged_includes + WITH + (TABLOCK) + ( + scope_hash, + index_name, + key_columns, + merged_includes + ) + SELECT + kd.scope_hash, + kd.index_name, + kd.key_columns, + kd.merged_includes + FROM + ( + SELECT + ia.scope_hash, + ia.index_name, + ia.key_columns, + original_includes = ia.included_columns, + merged_includes = + STUFF + ( + ( + SELECT + N', ' + + QUOTENAME(idc.column_name) + FROM #index_details AS idc + WHERE idc.is_included_column = 1 + AND + ( + /* The winner's own includes */ + idc.index_hash = ia.index_hash + /* Plus every duplicate it is about to disable */ + OR EXISTS + ( + SELECT + 1/0 + FROM #index_analysis AS loser + WHERE loser.scope_hash = ia.scope_hash + AND loser.key_filter_hash = ia.key_filter_hash + AND loser.target_index_name = ia.index_name + AND loser.action = N'DISABLE' + AND loser.consolidation_rule = N'Key Duplicate' + AND loser.index_hash = idc.index_hash + ) + ) + GROUP BY + idc.column_name + ORDER BY + idc.column_name + FOR + XML + PATH(''), + TYPE + ).value('text()[1]', 'nvarchar(max)'), + 1, + 2, + N'' + ) + FROM #index_analysis AS ia + WHERE ia.action = N'MERGE INCLUDES' + AND ia.consolidation_rule = N'Key Duplicate' + /* Leave the multi-winner groups handled above alone */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #merged_includes AS mi + WHERE mi.scope_hash = ia.scope_hash + AND mi.index_name = ia.index_name + ) + AND EXISTS + ( + SELECT + 1/0 + FROM #index_analysis AS loser_check + WHERE loser_check.scope_hash = ia.scope_hash + AND loser_check.key_filter_hash = ia.key_filter_hash + AND loser_check.target_index_name = ia.index_name + AND loser_check.action = N'DISABLE' + AND loser_check.consolidation_rule = N'Key Duplicate' + AND loser_check.included_columns IS NOT NULL + ) + AND ia.database_id = @current_database_id + ) AS kd + WHERE ISNULL(kd.merged_includes, N'') <> ISNULL(kd.original_includes, N'') + OPTION(RECOMPILE); + + /* + Apply them. Winners handled by the multi-winner path above already carry + their merged list, so this only moves the single-winner rows just recorded. + */ + UPDATE + ia + SET + ia.included_columns = mi.merged_includes + FROM #index_analysis AS ia + JOIN #merged_includes AS mi + ON mi.scope_hash = ia.scope_hash + AND mi.index_name = ia.index_name + WHERE ia.action = N'MERGE INCLUDES' + AND ia.consolidation_rule = N'Key Duplicate' + AND ISNULL(ia.included_columns, N'') <> ISNULL(mi.merged_includes, N'') + AND ia.database_id = @current_database_id OPTION(RECOMPILE); + IF @debug = 1 + BEGIN + SELECT + table_name = '#index_analysis after single-winner key duplicate merge', + ia.* + FROM #index_analysis AS ia + OPTION(RECOMPILE); + END; + /* Find indexes with same key columns where one has includes that are a subset of another */ IF @debug = 1 BEGIN @@ -4802,6 +5413,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. OR ia2.included_columns IS NULL OR LEN(ia1.included_columns) < LEN(ia2.included_columns) ) + WHERE ia1.database_id = @current_database_id OPTION(RECOMPILE); /* Update the subset indexes to be disabled, since supersets already contain their columns */ @@ -4820,6 +5432,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. JOIN #include_subset_dedupe AS isd ON ia.scope_hash = isd.scope_hash AND ia.index_name = isd.subset_index_name + WHERE ia.database_id = @current_database_id OPTION(RECOMPILE); /* Update the superset indexes to indicate they supersede the subset indexes */ @@ -4839,6 +5452,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. JOIN #include_subset_dedupe AS isd ON ia.scope_hash = isd.scope_hash AND ia.index_name = isd.superset_index_name + WHERE ia.database_id = @current_database_id OPTION(RECOMPILE); /* Update winning indexes that don't actually need changes to have action = N'KEEP' */ @@ -4859,6 +5473,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE mi.scope_hash = ia.scope_hash AND mi.index_name = ia.index_name ) + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Insert merge scripts for indexes */ @@ -5029,6 +5644,50 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE ia.action IN (N'MERGE INCLUDES', N'MAKE UNIQUE') /* Only create merge scripts for the indexes that should remain after merging */ AND ia.target_index_name IS NULL + /* + Backstop behind the Rule 3 target exclusions: CREATE INDEX ... WITH + (DROP_EXISTING = ON) can never succeed against a constraint-backed + index (Msg 1907), so no matter what upstream rules decide, never + emit a merge script for one. + + "Constraint-backed" means a primary key as well as a unique + constraint. is_unique_constraint is 0 for a primary key, so testing + it alone left PKs unguarded here. + */ + AND NOT EXISTS + ( + SELECT + 1/0 + FROM #index_details AS id_uc_guard + WHERE id_uc_guard.index_hash = ia.index_hash + AND + ( + id_uc_guard.is_unique_constraint = 1 + OR id_uc_guard.is_primary_key = 1 + ) + ) + /* + Second backstop, behind the Rule 7 / 7.5 guards: any script that + comes out of here as CREATE UNIQUE must not partition on a column + outside its own key, or SQL Server rejects it with Msg 1908 — and + the paired DROP CONSTRAINT would still succeed, leaving the table + with no uniqueness at all. + */ + AND + ( + ia.action <> N'MAKE UNIQUE' + AND ia.is_unique = 0 + OR NOT EXISTS + ( + SELECT + 1/0 + FROM #partition_stats AS ps_uq_guard + WHERE ps_uq_guard.index_hash = ia.index_hash + AND ps_uq_guard.partition_columns IS NOT NULL + AND CHARINDEX(ps_uq_guard.partition_columns, ia.key_columns) = 0 + ) + ) + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Debug which indexes are getting MERGE scripts */ @@ -5205,14 +5864,29 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id.is_included_column = 0 /* Get only one row per index */ AND id.key_ordinal > 0 WHERE ia.action = N'DISABLE' - /* Exclude unique constraints - they are handled by DISABLE CONSTRAINT scripts */ + /* + Exclude unique constraints - they are handled by DISABLE CONSTRAINT scripts. + + Primary keys are excluded here too, but they are NOT rerouted to that + path: a unique constraint can legitimately be replaced by a promoted + nonclustered index and dropped, a primary key never can. Excluding one + here means it gets no script at all, which is the only safe answer. + ALTER INDEX ... DISABLE against a primary key does not error - it + succeeds, and silently disables every foreign key referencing it, after + which orphan rows insert cleanly. A script that succeeds while being + wrong is the worst thing this procedure can emit. + */ AND NOT EXISTS ( SELECT 1/0 FROM #index_details AS id_uc WHERE id_uc.index_hash = ia.index_hash - AND id_uc.is_unique_constraint = 1 + AND + ( + id_uc.is_unique_constraint = 1 + OR id_uc.is_primary_key = 1 + ) ) /* Also exclude any index that is also going to be made unique in rule 7.5 */ AND NOT EXISTS @@ -5224,6 +5898,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia_unique.index_name = ia.index_name AND ia_unique.action = N'MAKE UNIQUE' ) + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Add clustered indexes to #index_analysis specifically for compression purposes */ @@ -5282,6 +5957,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id2.object_id = fo.object_id AND id2.index_id = fo.index_id AND id2.is_included_column = 0 + /* + A partitioned index's partitioning column rides along at + key_ordinal = 0 when it is not a real key. Without this it was + emitted as a phantom LEADING key, corrupting rebuilt indexes + and poisoning duplicate/subset hashing. + */ + AND id2.key_ordinal > 0 GROUP BY id2.column_name, id2.is_descending_key, @@ -5334,6 +6016,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id2.object_id = fo.object_id AND id2.index_id = fo.index_id AND id2.is_included_column = 0 + /* + A partitioned index's partitioning column rides along at + key_ordinal = 0 when it is not a real key. Without this it was + emitted as a phantom LEADING key, corrupting rebuilt indexes + and poisoning duplicate/subset hashing. + */ + AND id2.key_ordinal > 0 GROUP BY id2.column_name, id2.is_descending_key, @@ -5394,6 +6083,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE id2.object_id = fo.object_id AND id2.index_id = fo.index_id AND id2.is_included_column = 0 + /* + A partitioned index's partitioning column rides along at + key_ordinal = 0 when it is not a real key. Without this it was + emitted as a phantom LEADING key, corrupting rebuilt indexes + and poisoning duplicate/subset hashing. + */ + AND id2.key_ordinal > 0 GROUP BY id2.column_name, id2.is_descending_key, @@ -5463,6 +6159,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. OR id.is_primary_key = 1 ) AND ce.can_compress = 1 /* Only those eligible for compression */ + AND fo.database_id = @current_database_id /* Only add if not already in #index_analysis */ AND NOT EXISTS ( @@ -5481,7 +6178,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SET #index_analysis.action = N'KEEP' WHERE #index_analysis.index_id = 1 /* Clustered indexes */ - AND #index_analysis.action IS NULL; + AND #index_analysis.action IS NULL + AND #index_analysis.database_id = @current_database_id; /* Update index priority for clustered indexes to ensure they're not chosen for deduplication */ UPDATE @@ -5489,7 +6187,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SET #index_analysis.index_priority = 1000 /* Maximum priority */ WHERE #index_analysis.index_id = 1 /* Clustered indexes */ - AND #index_analysis.index_priority IS NULL; + AND #index_analysis.index_priority IS NULL + AND #index_analysis.database_id = @current_database_id; IF @debug = 1 BEGIN @@ -5622,6 +6321,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. (ia.action IS NULL OR ia.action = N'KEEP') /* Only indexes eligible for compression */ AND ce.can_compress = 1 + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Insert disable scripts for unique constraints */ @@ -5697,6 +6397,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ia_uc.action = N'DISABLE' /* That have consolidation_rule of 'Unique Constraint Replacement' */ AND ia_uc.consolidation_rule = N'Unique Constraint Replacement' + AND ia_uc.database_id = @current_database_id OPTION(RECOMPILE); /* Insert per-partition compression scripts */ @@ -5822,6 +6523,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND (ia.action IS NULL OR ia.action = N'KEEP') /* Only indexes eligible for compression */ AND ce.can_compress = 1 + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* Insert compression ineligible info */ @@ -5885,6 +6587,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id.is_included_column = 0 /* Get only one row per index */ AND id.key_ordinal > 0 WHERE ce.can_compress = 0 + AND ce.database_id = @current_database_id OPTION(RECOMPILE); @@ -5948,6 +6651,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND id.is_included_column = 0 /* Get only one row per index */ AND id.key_ordinal > 0 WHERE ia.action = N'REVIEW' + AND ia.database_id = @current_database_id OPTION(RECOMPILE); @@ -6104,6 +6808,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND ia.index_id > 0 ) ) + AND ia.database_id = @current_database_id OPTION(RECOMPILE); /* @@ -6813,7 +7518,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. END, schema_name = 'ALWAYS TEST THESE RECOMMENDATIONS', table_name = 'IN A NON-PRODUCTION ENVIRONMENT FIRST!', - tables_analyzed = FORMAT(irs.tables_analyzed, 'N0'), + /* ISNULL to match the DATABASE and TABLE levels below: an + empty scope rendered this as NULL instead of 0 */ + tables_analyzed = FORMAT(ISNULL(irs.tables_analyzed, 0), 'N0'), total_indexes = FORMAT(ISNULL(irs.index_count, 0), 'N0'), removable_indexes = FORMAT(ISNULL(irs.indexes_to_disable, 0), 'N0'), mergeable_indexes = FORMAT(ISNULL(irs.indexes_to_merge, 0), 'N0'), diff --git a/sp_IndexCleanup/tests/README.md b/sp_IndexCleanup/tests/README.md new file mode 100644 index 00000000..25aee97d --- /dev/null +++ b/sp_IndexCleanup/tests/README.md @@ -0,0 +1,152 @@ +# sp_IndexCleanup Tests + +Two kinds of thing live here: an automated suite that asserts, and fixture +scripts that set up realistic scenarios for you to inspect by hand. + +**Run the automated suite before and after any change to `sp_IndexCleanup.sql`.** +Compiling proves nothing about behavior. The procedure's whole output is +generated DDL, and a script that looks right but cannot execute is worse than no +script at all -- the paired `DISABLE` still runs. + +## Automated + +| Script | What it does | +| --- | --- | +| `run_tests.py` | Runs `adversarial_test.sql`, parses the output, asserts 28 expectations across 12 rule groups. | +| `adversarial_test.sql` | Builds synthetic `test_ic_*` tables covering unique constraints, sort directions, filters, include merges, indexed views, heaps, and rule interactions. Self-cleaning. Driven by `run_tests.py`; run it directly only to eyeball output. | +| `fixture_cases_test.py` | Drives `fixtures_more_dupe_indexes.sql` and asserts the `Expected:` comments in it -- cases 1 through 8d -- against the real `Users` table. **Also executes every generated MERGE/DISABLE script** inside a rolled-back transaction. ~2 minutes. | +| `rule_coverage_test.py` | Small synthetic fixture in `Crap`. Covers unused-index detection (or the uptime guard, see below) and the `@min_reads`/`@min_writes` index-level screen. Fast. | +| `no_access_test.py` | Creates a login with no user-database access and asserts the `HAS_DBACCESS` preflight returns instead of spinning at 100% CPU forever (PerformanceMonitor #915). Drops the login afterward. | + +``` +cd sp_IndexCleanup/tests +python run_tests.py --server SQL2022 +python fixture_cases_test.py --server SQL2022 +python rule_coverage_test.py --server SQL2022 +python no_access_test.py --server SQL2022 +``` + +All take `--server` and `--password` (default `SQL2022` / the standard local sa +password). Expect `28`, `31`, `11`, and `4` passed. + +### The execute check is the one that earns its keep + +`fixture_cases_test.py` runs every generated script for real, each in its own +`BEGIN TRANSACTION ... ROLLBACK`. That is not belt-and-braces -- it is the check +that catches this procedure's characteristic bug. + +Every defect found in it so far has had the same shape: **a merge script that +cannot execute, paired with a `DISABLE` that can**, so the loser's covering +column disappears with nothing absorbing it. Compiling does not catch it. Reading +the output does not catch it. Only running the script does. + +Pointed at the build from before those fixes, this suite goes red on exactly +those bugs, including `Msg 1907 - Cannot recreate index ... does not match the +constraint being enforced by the existing index` surfacing purely from the +execute check. `SET PARSEONLY ON` would sail straight past it: 1907 is a semantic +error, not a syntax one. + +### Uptime guard: unused-index detection cannot run on a fresh instance + +`sp_IndexCleanup` auto-enables `@dedupe_only` when server uptime is <= 7 days, +which skips Rule 1 entirely. That is deliberate -- usage stats are meaningless on +a just-restarted server -- and it is not overridable by parameter. + +The practical consequence: **on a freshly restarted instance, unused-index +detection will look broken when it is not.** Run with `@debug = 1` and you will +see the procedure say so: `Server uptime is less than 7 days. Automatically +enabling @dedupe_only mode.` Check that before concluding Rule 1 is broken. + +`rule_coverage_test.py` handles this by asserting whichever is true: on a +long-uptime instance it asserts Rule 1 flags the unused index; on a fresh one it +asserts the guard fired and suppressed it. It never silently skips, and it proves +the index was analyzed and genuinely has zero reads first, so the absence of an +`Unused Index` row means the guard, not an invisible index. + +### CI + +All four runners run in CI on every 2017/2019/2022/2025 container. Three of them +(`run_tests.py`, `fixture_cases_test.py`, `no_access_test.py`) need a +`StackOverflow2013` with a `Users` table, which the containers do not have, so CI +first builds a faithful-schema synthetic one from `fixture_stackoverflow2013.sql` +(exact Users columns, clustered PK, the `DropIndexes` helper, ~500k varied rows). +Real values are not needed: the harness forces its index reads and every rule is +structural. + +`rule_coverage_test.py` creates its own scratch `Crap`/`CrapA`/`CrapB` +databases. It used to assume `Crap` already existed -- true on a long-lived box +where it persists between runs, false on a fresh CI container -- and the missing +database failed silently (the `-d Crap` connection error is Msg 4060, Level 11, +below the Level-16 error check). It now creates `Crap` if absent and treats Msg +4060/911 as fatal, so it runs cleanly on a fresh instance. + +## Fixtures (manual) + +These build indexes on the real `StackOverflow2013.dbo.Users` table (~2.4M rows), +so unlike the synthetic suite they exercise real data distribution and sizes. + +| Script | What it does | +| --- | --- | +| `fixtures_dupe_indexes.sql` | ~24 indexes covering duplicates, subsets, sort directions, filters, mergeable includes, and deliberately unused indexes. | +| `generate_index_reads.sql` | Drives ~100 reads through each of the above so usage stats exist. ~2 minutes. | +| `fixtures_more_dupe_indexes.sql` | Numbered cases 1-8d, each annotated with its expected behavior. The only fixtures covering unique **constraints** (7a/7b/7c) next to unique **indexes** (3, 5). Self-contained: builds, generates reads, and runs the procedure. | +| `manual_test_runs.sql` | Assorted invocations -- scoping, filters, `@dedupe_only`, `@help`. | + +Order for the first set: + +``` +fixtures_dupe_indexes.sql -> generate_index_reads.sql -> manual_test_runs.sql +``` + +`fixtures_more_dupe_indexes.sql` stands alone. + +### Prerequisites + +- A **scratch** `StackOverflow2013`. Both fixture scripts begin with + `EXECUTE dbo.DropIndexes`, which drops every nonclustered index in the + database. Do not point them at anything you care about. +- A `dbo.DropIndexes` helper procedure in that database. +- `fixtures_more_dupe_indexes.sql` adds three unique constraints + (`uq_test_c1`/`c2`/`c3`). `DropIndexes` does not remove constraints -- drop + them explicitly with `ALTER TABLE dbo.Users DROP CONSTRAINT ...` when + cleaning up. + +### The `Expected:` comments are the specification + +They are not decoration. Two of them caught bugs that shipped, that compiled +cleanly, and that the automated suite did not cover: + +- **Case 7c** -- the procedure emitted a merge into a unique *constraint*: + `CREATE INDEX ... WITH (DROP_EXISTING = ON)` against a constraint-backed + index, which SQL Server refuses with **Msg 1907**. The paired `DISABLE` of + `ix_c3` ran fine, so the net effect was losing an index whose include nothing + absorbed. +- **Case 3** -- `ix_test_5` was disabled with `uq_test_1` as its target, but + nothing merged `ix_test_5`'s include into it, so `LastAccessDate` coverage + vanished. A plain unique *index* does accept `DROP_EXISTING` with an added + `INCLUDE` (unlike a constraint), so the merge is legal and now happens. + +Both were found by diffing output against these comments by hand. They are now +asserted automatically by `fixture_cases_test.py`, so a regression against either +fails a test rather than waiting to be noticed. + +## Verifying a change + +The technique that catches regressions is a **baseline diff**: identical +database state, two builds, compare output. + +``` +git show :sp_IndexCleanup/sp_IndexCleanup.sql > /tmp/base.sql +``` + +Build fixtures once, generate reads once, then install each build in turn and +capture output. Normalize the `run date:` stamp before diffing, and compare rows +as a set -- a removed row shifts every line after it and makes a positional diff +useless. + +Then ask of every difference: intended, or a regression? A change with no +explanation is the finding. + +Finally, for anything that alters generated DDL: **execute the generated script** +against the fixture and confirm the result. That is the check that distinguishes +a script that reads correctly from one that runs. diff --git a/sp_IndexCleanup/tests/__pycache__/run_tests.cpython-313.pyc b/sp_IndexCleanup/tests/__pycache__/run_tests.cpython-313.pyc deleted file mode 100644 index 64185044..00000000 Binary files a/sp_IndexCleanup/tests/__pycache__/run_tests.cpython-313.pyc and /dev/null differ diff --git a/sp_IndexCleanup/tests/adversarial_test.sql b/sp_IndexCleanup/tests/adversarial_test.sql index cf94ecca..68a0458e 100644 --- a/sp_IndexCleanup/tests/adversarial_test.sql +++ b/sp_IndexCleanup/tests/adversarial_test.sql @@ -216,9 +216,21 @@ SELECT TOP (10000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), ABS(CHECKSUM(NEWID())) % 500, ABS(CHECKSUM(NEWID())) % 200 FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b; +/* +col_c is ROW_NUMBER(), not a random value, and that matters. Group 12b creates +uq_int_cd UNIQUE (col_c, col_d) below. With col_c and col_d drawn randomly from +200 and 100 values across 10,000 rows, duplicate pairs were a certainty, so the +constraint failed to create on EVERY run with Msg 1505, Msg 1750 followed, and +the read loop's forced hint on the missing index aborted the usage batch with +Msg 308. Group 12b was never actually tested, and the suite could not see any of +it because the runner checked stderr while go-sqlcmd reports errors on stdout. + +A unique col_c makes (col_c, col_d) unique regardless of col_d. col_a and col_b +stay duplicated on purpose - the subset chain in 12a needs repeated values. +*/ INSERT INTO dbo.test_ic_interact (col_a, col_b, col_c, col_d, col_e) SELECT TOP (10000) ABS(CHECKSUM(NEWID())) % 1000, ABS(CHECKSUM(NEWID())) % 500, - ABS(CHECKSUM(NEWID())) % 200, ABS(CHECKSUM(NEWID())) % 100, LEFT(NEWID(), 20) + ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), ABS(CHECKSUM(NEWID())) % 100, LEFT(NEWID(), 20) FROM sys.all_objects AS a CROSS JOIN sys.all_objects AS b; GO @@ -301,9 +313,29 @@ CREATE INDEX ix_int_ab ON dbo.test_ic_interact (col_a, col_b); CREATE INDEX ix_int_abc ON dbo.test_ic_interact (col_a, col_b, col_c); /* 12b: UC exact match AND UC superset on same table */ +/* +This group is the one shape where three rules meet, and every piece of it is +load-bearing: + + uq_int_cd the unique CONSTRAINT that Rule 7.5 wants to replace + ix_int_cd a key duplicate of it, with its own include + ix_int_cd2 a SECOND key duplicate with a DIFFERENT include, which is what + routes the winner through Rule 7.6 at all + ix_int_c a key SUBSET carrying an include of its own + +Rule 6 merges ix_int_c's col_b into the winner as a Key Subset. Rule 7.5 then +rewrites that same row to MAKE UNIQUE. Rule 7.6 recomputes the winner's includes, +and if it gathers only Key Duplicate losers it silently drops col_b while +ix_int_c's DISABLE still runs - a covering column gone, on scripts that all +execute cleanly, which the execute check cannot see. + +Drop ix_int_cd2 and the interaction stops firing: with one duplicate there is no +7.6 pass to lose anything. That is why a fixture without it looked healthy. +*/ ALTER TABLE dbo.test_ic_interact ADD CONSTRAINT uq_int_cd UNIQUE (col_c, col_d); CREATE INDEX ix_int_cd ON dbo.test_ic_interact (col_c, col_d) INCLUDE (col_e); -CREATE INDEX ix_int_c ON dbo.test_ic_interact (col_c); +CREATE INDEX ix_int_cd2 ON dbo.test_ic_interact (col_c, col_d) INCLUDE (col_a); +CREATE INDEX ix_int_c ON dbo.test_ic_interact (col_c) INCLUDE (col_b); /* Group 13: @min_reads filter — run separately in Python */ GO diff --git a/sp_IndexCleanup/tests/fixture_cases_test.py b/sp_IndexCleanup/tests/fixture_cases_test.py new file mode 100644 index 00000000..fd1e0c14 --- /dev/null +++ b/sp_IndexCleanup/tests/fixture_cases_test.py @@ -0,0 +1,560 @@ +""" +sp_IndexCleanup Fixture Case Assertions +======================================= +Drives fixtures_more_dupe_indexes.sql and asserts the "Expected:" comments in it. +Those comments are the specification: two of them caught bugs that shipped, that +compiled cleanly, and that the adversarial suite did not cover. Until now nothing +asserted them automatically -- the README called wiring them up "the most +valuable improvement available to this directory". This is that. + +The fixture is self-contained: it drops every nonclustered index in the database, +builds cases 1 through 8d on StackOverflow2013.dbo.Users, drives reads through +each one, and ends by running the procedure with @dedupe_only = 1. It takes about +two minutes, most of it in the read-generation loop. + +HOW GENERATED SCRIPTS ARE VALIDATED: they are EXECUTED, not parsed. + +Every MERGE SCRIPT, DISABLE SCRIPT, and DISABLE CONSTRAINT SCRIPT in the output +is run for real, each inside its own BEGIN TRANSACTION ... ROLLBACK, via +sp_executesql inside TRY/CATCH so that a compile error surfaces as a catchable +error rather than killing the batch. Each script is rolled back individually, so +database state is identical before and after and the captured output stays valid. + +This matters, and SET PARSEONLY ON would not do. Every bug found in this +procedure so far had the same shape: a script that reads correctly and parses +correctly but cannot execute. The canonical example is case 7c, where the +procedure emitted a merge into a unique constraint and SQL Server rejected it +with Msg 1907 ("The new index definition does not match the constraint being +enforced by the existing index") -- a semantic error that PARSEONLY sails right +past, while the paired DISABLE ran fine and quietly cost a covering index. +Executing is the only check that tells the two apart. + +Cleanup: drops the three unique constraints (uq_test_c1/c2/c3 -- DropIndexes does +not reliably remove constraints) and runs StackOverflow2013.dbo.DropIndexes. + +Prerequisites: + - A scratch StackOverflow2013 (the fixture drops every nonclustered index). + - A dbo.DropIndexes helper procedure in that database. + - sp_IndexCleanup installed in master. + +Usage: + python fixture_cases_test.py [--server SQL2022] [--password "L!nt0044"] +""" + +import os +import re +import shlex +import subprocess +import sys +import tempfile + + +HERE = os.path.dirname(os.path.abspath(__file__)) +FIXTURE_FILE = os.path.join(HERE, "fixtures_more_dupe_indexes.sql") + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment + so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to + 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the + go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + container's self-signed cert and disable encryption, which the modern Go + TLS stack needs to connect to the SQL Server 2017 container.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + +# Rows that represent a dedupe action. A COMPRESSION SCRIPT row is not one of +# these: an index that gets no dedupe action still gets compression advice, and +# that is expected, so "no dedupe action" means none of these three. +DEDUPE_SCRIPT_TYPES = ("DISABLE SCRIPT", "MERGE SCRIPT", "DISABLE CONSTRAINT SCRIPT") + + +def run_sqlcmd(server, password, input_file=None, query=None, + database="StackOverflow2013", timeout=600): + """Run SQL from a file or a query string and capture output.""" + cmd = [ + *_sqlcmd_prefix(), + "-S", server, "-U", "sa", "-P", password, + "-d", database, + "-W", # trim trailing spaces + "-s", "\t", # tab delimiter + ] + if input_file is not None: + cmd += ["-i", input_file] + else: + cmd += ["-Q", query] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return result.stdout, result.stderr + + +def parse_output(stdout): + """ + Parse sp_IndexCleanup tab-delimited output into rows. + + Bounded to the script result set. The procedure emits several result sets and + the ones after this share nothing but a tab delimiter, so parsing stops at the + blank line that ends this one. Without that bound the summary rows get zipped + against these headers and land in the same list, which matters here because + several assertions below are assertions of absence. + + Column order, verified empirically against the running procedure: + script_type, additional_info, database_name, schema_name, table_name, + index_name, consolidation_rule, target_index_name, superseded_info, + index_size_gb, index_rows, index_reads, index_writes, + original_index_definition, script + """ + rows = [] + lines = stdout.split("\n") + headers = None + started = False + + for line in lines: + if headers is None: + if "script_type" in line and "index_name" in line: + headers = [h.strip() for h in line.split("\t")] + continue + + if line.startswith("---"): + continue + + if not line.strip(): + # Blank line ends the result set once rows have started. + if started: + break + continue + + cols = [c.strip() for c in line.split("\t")] + if len(cols) >= len(headers): + rows.append(dict(zip(headers, cols))) + started = True + + return rows + + +def find_rows(rows, **filters): + """Find rows matching all filter criteria.""" + matches = [] + for row in rows: + match = True + for key, value in filters.items(): + if key.endswith("__like"): + col = key[:-6] + if col not in row or value.lower() not in row[col].lower(): + match = False + break + elif key.endswith("__in"): + col = key[:-4] + if col not in row or row[col] not in value: + match = False + break + else: + if key not in row or row[key] != value: + match = False + break + if match: + matches.append(row) + return matches + + +def dedupe_rows(rows, index_name): + """Every dedupe-action row for an index. Compression rows are not included.""" + return [ + r for r in rows + if r.get("index_name") == index_name + and r.get("script_type") in DEDUPE_SCRIPT_TYPES + ] + + +def include_columns(script): + """The contents of a script's INCLUDE (...) clause, or '' if it has none.""" + match = re.search(r"INCLUDE\s*\(([^)]*)\)", script or "", re.IGNORECASE) + return match.group(1) if match else "" + + +def script_rows(rows): + """Every generated script row worth executing, in output order.""" + seen = [] + for r in rows: + if r.get("script_type") not in DEDUPE_SCRIPT_TYPES: + continue + script = r.get("script", "") + if not script or script == "NULL": + continue + seen.append(r) + return seen + + +def validate_scripts(server, password, rows): + """ + Execute every generated script inside its own rolled-back transaction. + + sp_executesql inside TRY/CATCH, so a compile error in a generated script is + catchable instead of aborting the batch. Returns a list of + (index_name, script_type, error_or_None). + """ + targets = script_rows(rows) + if not targets: + return [] + + batches = [ + "SET NOCOUNT ON;", + "SET XACT_ABORT OFF;", + ] + for i, r in enumerate(targets): + escaped = r["script"].replace("'", "''") + tag = "SCRIPTCHECK~%d~" % i + batches.append( + "BEGIN TRY\n" + " BEGIN TRANSACTION;\n" + " EXECUTE sys.sp_executesql N'" + escaped + "';\n" + " IF @@TRANCOUNT > 0 ROLLBACK;\n" + " SELECT r = CONVERT(varchar(1000), '" + tag + "OK');\n" + "END TRY\n" + "BEGIN CATCH\n" + " IF @@TRANCOUNT > 0 ROLLBACK;\n" + " SELECT r = CONVERT(varchar(1000), '" + tag + "ERROR: Msg ' + " + "CONVERT(varchar(10), ERROR_NUMBER()) + ' - ' + ERROR_MESSAGE());\n" + "END CATCH;" + ) + + path = None + try: + handle, path = tempfile.mkstemp(suffix=".sql", prefix="ic_scriptcheck_") + with os.fdopen(handle, "w") as f: + f.write("\nGO\n".join(batches) + "\nGO\n") + + stdout, _ = run_sqlcmd(server, password, input_file=path, timeout=600) + finally: + if path and os.path.exists(path): + os.remove(path) + + results = {} + for line in stdout.split("\n"): + line = line.strip() + if not line.startswith("SCRIPTCHECK~"): + continue + _, index, outcome = line.split("~", 2) + results[int(index)] = outcome + + out = [] + for i, r in enumerate(targets): + outcome = results.get(i) + if outcome is None: + error = "no result captured for this script" + elif outcome == "OK": + error = None + else: + error = outcome + out.append((r["index_name"], r["script_type"], error)) + return out + + +def run_tests(rows, script_results): + """Run all assertions and return results.""" + results = [] + + def assert_test(group, name, condition, detail=""): + results.append({ + "group": group, + "name": name, + "passed": condition, + "detail": detail, + }) + + # ---- Case 1: Exact duplicates (same keys, same includes) ---- + # ix_test_1 / ix_test_2 on (DisplayName) INCLUDE (Reputation). + + matches = find_rows(rows, table_name="Users", index_name="ix_test_2", + script_type="DISABLE SCRIPT") + assert_test("1-Exact-Dup", "ix_test_2 disabled (exact duplicate of ix_test_1)", + len(matches) == 1, "found %d" % len(matches)) + + # ---- Case 2: Key duplicates with different includes ---- + # ix_test_3 INCLUDE (Reputation) / ix_test_4 INCLUDE (UpVotes) on (AccountId). + # Expected: one kept with includes merged, other disabled. + + matches = find_rows(rows, table_name="Users", index_name="ix_test_4", + script_type="DISABLE SCRIPT") + assert_test("2-Key-Dup", "ix_test_4 disabled (key duplicate of ix_test_3)", + len(matches) == 1, "found %d" % len(matches)) + + matches = find_rows(rows, table_name="Users", index_name="ix_test_3", + script_type="MERGE SCRIPT") + includes = include_columns(matches[0]["script"]) if matches else "" + merged = ("Reputation" in includes) and ("UpVotes" in includes) + assert_test("2-Key-Dup", "ix_test_3 merged, INCLUDE has both Reputation and UpVotes", + len(matches) == 1 and merged, + "found %d merge rows, INCLUDE=(%s)" % (len(matches), includes)) + + # ---- Case 3: Key duplicates, one a unique INDEX ---- + # uq_test_1 UNIQUE (Location, Id) / ix_test_5 (Location, Id) INCLUDE + # (LastAccessDate). Expected: unique index kept, includes merged in. + # + # This is one of the two cases that caught a shipped bug: ix_test_5 used to be + # disabled with uq_test_1 as its target while nothing merged the include into + # uq_test_1, silently dropping LastAccessDate coverage. Unlike a constraint, a + # plain unique index does accept DROP_EXISTING with an added INCLUDE, so the + # merge is legal and must happen. + + matches = find_rows(rows, table_name="Users", index_name="uq_test_1", + script_type="MERGE SCRIPT") + script = matches[0]["script"] if matches else "" + assert_test("3-Unique-Index", "uq_test_1 merged and script has CREATE UNIQUE", + len(matches) == 1 and "CREATE UNIQUE" in script, + "found %d merge rows, CREATE UNIQUE=%s" + % (len(matches), "CREATE UNIQUE" in script)) + + includes = include_columns(script) + assert_test("3-Unique-Index", "uq_test_1 INCLUDE absorbed LastAccessDate", + "LastAccessDate" in includes, "INCLUDE=(%s)" % includes) + + matches = find_rows(rows, table_name="Users", index_name="ix_test_5", + script_type="DISABLE SCRIPT") + assert_test("3-Unique-Index", "ix_test_5 disabled (its include went to uq_test_1)", + len(matches) == 1, "found %d" % len(matches)) + + # ---- Case 4: Superset/subset keys, neither unique ---- + # ix_test_6 (Age) is a subset of ix_test_7 (Age, CreationDate). + + matches = find_rows(rows, table_name="Users", index_name="ix_test_6", + script_type="DISABLE SCRIPT") + assert_test("4-Key-Subset", "ix_test_6 disabled (subset of ix_test_7)", + len(matches) == 1, "found %d" % len(matches)) + + # ---- Case 5: Superset/subset with a unique narrower index ---- + # uq_test_2 UNIQUE (EmailHash, Id) / ix_test_8 (EmailHash, Id, WebsiteUrl). + # Expected: both kept. A unique index must not be folded into a wider + # non-unique one -- the wider index cannot enforce the uniqueness. + + matches = dedupe_rows(rows, "uq_test_2") + assert_test("5-Unique-Subset", "uq_test_2 gets no dedupe action", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + matches = dedupe_rows(rows, "ix_test_8") + assert_test("5-Unique-Subset", "ix_test_8 gets no dedupe action", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + # ---- Case 6: Mismatched key orders (Rule 8) ---- + # ix_test_9 (CreationDate, LastAccessDate, Views) vs + # ix_test_10 (CreationDate, Views, LastAccessDate). + # Expected: flagged "Same Keys Different Order" for review. This surfaces as + # an analysis report row with script_type NEEDS REVIEW and a NULL script, not + # as generated DDL, so it is not a dedupe action and must not be one. + + matches = [ + r for r in rows + if r.get("index_name") in ("ix_test_9", "ix_test_10") + and r.get("script_type") == "NEEDS REVIEW" + and r.get("consolidation_rule") == "Same Keys Different Order" + ] + targets_each_other = all( + m.get("target_index_name") in ("ix_test_9", "ix_test_10") for m in matches + ) + assert_test("6-Key-Order", "ix_test_9/ix_test_10 flagged Same Keys Different Order", + len(matches) >= 1 and targets_each_other, + "found %d review rows (%s)" + % (len(matches), + [(m["index_name"], m["target_index_name"]) for m in matches])) + + # ---- Case 7a: Unique CONSTRAINT with an exact-match nonclustered index ---- + # uq_test_c1 UNIQUE (DownVotes, Id) / ix_c1 (DownVotes, Id) INCLUDE (AboutMe). + # Expected: constraint dropped, index made unique to take over enforcement. + + matches = find_rows(rows, table_name="Users", index_name="uq_test_c1", + script_type="DISABLE CONSTRAINT SCRIPT") + assert_test("7a-UC-Replace", "uq_test_c1 gets DISABLE CONSTRAINT", + len(matches) == 1, "found %d" % len(matches)) + + matches = find_rows(rows, table_name="Users", index_name="ix_c1", + script_type="MERGE SCRIPT") + script = matches[0]["script"] if matches else "" + assert_test("7a-UC-Replace", "ix_c1 merged and script has CREATE UNIQUE", + len(matches) == 1 and "CREATE UNIQUE" in script, + "found %d merge rows, CREATE UNIQUE=%s" + % (len(matches), "CREATE UNIQUE" in script)) + + # ---- Case 7b: Unique constraint vs a WIDER nonclustered index ---- + # uq_test_c2 UNIQUE (UpVotes, Id) / ix_c2 (UpVotes, Id, Reputation). + # Expected: no action, keys do not match exactly. + + matches = dedupe_rows(rows, "uq_test_c2") + assert_test("7b-UC-Wider-NC", "uq_test_c2 gets no dedupe action", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + matches = dedupe_rows(rows, "ix_c2") + assert_test("7b-UC-Wider-NC", "ix_c2 gets no dedupe action", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + # ---- Case 7c: Unique constraint WIDER than the nonclustered index ---- + # uq_test_c3 UNIQUE (Id, DisplayName) / ix_c3 (Id) INCLUDE (LastAccessDate). + # Expected: no action, keys do not match exactly. + # + # Load-bearing. The procedure used to emit a merge into the constraint -- + # CREATE INDEX ... WITH (DROP_EXISTING = ON) against a constraint-backed + # index, which SQL Server rejects with Msg 1907 -- while still emitting a + # runnable DISABLE of ix_c3. The merge failed, the disable succeeded, and + # ix_c3's LastAccessDate coverage vanished with nothing absorbing it. The + # dividing line is is_unique_constraint, not is_unique: contrast case 3, + # where a plain unique index is a legal merge target. + + matches = dedupe_rows(rows, "uq_test_c3") + assert_test("7c-UC-Wider", "uq_test_c3 gets no dedupe action (Msg 1907 merge)", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + matches = dedupe_rows(rows, "ix_c3") + assert_test("7c-UC-Wider", "ix_c3 gets no dedupe action (would lose LastAccessDate)", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + # ---- Case 8a: Filtered indexes with matching filters ---- + # ix_filtered_1 / ix_filtered_2, both WHERE (Reputation > 1000). + + matches = find_rows(rows, table_name="Users", index_name="ix_filtered_2", + script_type="DISABLE SCRIPT") + assert_test("8a-Filter-Match", "ix_filtered_2 disabled (same filter as ix_filtered_1)", + len(matches) == 1, "found %d" % len(matches)) + + # ---- Case 8b: Filtered index with a different filter ---- + # ix_filtered_3 WHERE (Reputation > 2000). Covers different rows, keep it. + + matches = dedupe_rows(rows, "ix_filtered_3") + assert_test("8b-Filter-Diff", "ix_filtered_3 gets no dedupe action (different filter)", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + # ---- Case 8c: Matching descending sort orders ---- + # ix_desc_1 / ix_desc_2, both (Reputation DESC). + + matches = find_rows(rows, table_name="Users", index_name="ix_desc_2", + script_type="DISABLE SCRIPT") + assert_test("8c-Sort-Match", "ix_desc_2 disabled (same sort as ix_desc_1)", + len(matches) == 1, "found %d" % len(matches)) + + # ---- Case 8d: Different sort direction ---- + # ix_desc_3 (Reputation ASC). Different ordering, different query patterns. + + matches = dedupe_rows(rows, "ix_desc_3") + assert_test("8d-Sort-Diff", "ix_desc_3 gets no dedupe action (different sort)", + len(matches) == 0, + "found %d (%s)" % (len(matches), [m["script_type"] for m in matches])) + + # ---- Every generated script must actually execute ---- + # A script that reads correctly but cannot run is the bug this procedure keeps + # producing. Each was executed inside a rolled-back transaction. + + for index_name, script_type, error in script_results: + assert_test("9-Executable", "%s (%s) executes" % (index_name, script_type), + error is None, error if error else "rolled back clean") + + return results + + +def cleanup(server, password): + """ + Drop the three unique constraints and every nonclustered index the fixture + built. DropIndexes does not reliably remove constraints, so they go first and + explicitly. + """ + query = """ +SET NOCOUNT ON; +IF EXISTS (SELECT 1/0 FROM sys.key_constraints AS kc WHERE kc.name = N'uq_test_c1') +BEGIN + ALTER TABLE dbo.Users DROP CONSTRAINT uq_test_c1; +END; +IF EXISTS (SELECT 1/0 FROM sys.key_constraints AS kc WHERE kc.name = N'uq_test_c2') +BEGIN + ALTER TABLE dbo.Users DROP CONSTRAINT uq_test_c2; +END; +IF EXISTS (SELECT 1/0 FROM sys.key_constraints AS kc WHERE kc.name = N'uq_test_c3') +BEGIN + ALTER TABLE dbo.Users DROP CONSTRAINT uq_test_c3; +END; +EXECUTE StackOverflow2013.dbo.DropIndexes; +""" + run_sqlcmd(server, password, query=query, timeout=600) + + +def main(): + server = "SQL2022" + password = "L!nt0044" + + # Parse args + args = sys.argv[1:] + for i, arg in enumerate(args): + if arg == "--server" and i + 1 < len(args): + server = args[i + 1] + elif arg == "--password" and i + 1 < len(args): + password = args[i + 1] + + print("Running fixture case tests against %s..." % server) + print("Building fixtures and generating reads (about two minutes)...") + print() + + try: + stdout, stderr = run_sqlcmd(server, password, input_file=FIXTURE_FILE, + timeout=600) + + # go-sqlcmd reports SQL errors on STDOUT, so checking stderr alone made + # this decorative: a fixture could fail partway through and the suite + # would still go green, asserting against a half-built schema. Match any + # severity 16+ on both streams. + errors = (re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stdout or "") + + re.findall(r"Msg \d+, Level 1[6-9][^\n]*", stderr or "")) + + if errors: + print("ERROR: SQL errors detected while building the fixture:") + for e in errors: + print(" " + e) + print() + print("The fixture did not build correctly, so the assertions below") + print("would be testing something other than what they claim.") + sys.exit(1) + + rows = parse_output(stdout) + print("Captured %d output rows from sp_IndexCleanup" % len(rows)) + + if len(rows) == 0: + print("ERROR: No output rows captured. Check SQL setup.") + print("stderr:", stderr[:500] if stderr else "(empty)") + sys.exit(1) + + targets = script_rows(rows) + print("Executing %d generated scripts in rolled-back transactions..." + % len(targets)) + print() + script_results = validate_scripts(server, password, rows) + + results = run_tests(rows, script_results) + finally: + cleanup(server, password) + + # Report + passed = sum(1 for r in results if r["passed"]) + failed = sum(1 for r in results if not r["passed"]) + + for r in results: + status = "PASS" if r["passed"] else "FAIL" + print(" [%s] %s: %s (%s)" % (status, r["group"], r["name"], r["detail"])) + + print() + print("Results: %d passed, %d failed, %d total" % (passed, failed, len(results))) + + if failed > 0: + print() + print("FAILED TESTS:") + for r in results: + if not r["passed"]: + print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"])) + sys.exit(1) + else: + print("All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/sp_IndexCleanup/tests/fixture_stackoverflow2013.sql b/sp_IndexCleanup/tests/fixture_stackoverflow2013.sql new file mode 100644 index 00000000..cc916a39 --- /dev/null +++ b/sp_IndexCleanup/tests/fixture_stackoverflow2013.sql @@ -0,0 +1,394 @@ +/* +============================================================================= +sp_IndexCleanup test fixture: a scratch StackOverflow2013 for CI +============================================================================= + +Erik's lab boxes already have the real StackOverflow2013 (~2.4M-row Users), +which the harness assumes. The CI containers do not, so this builds a +faithful-schema, synthetic-data stand-in that lets the whole IndexCleanup +suite run anywhere. + +What the harness actually needs from this database: + + - run_tests.py connects here and builds its own test_ic_* tables, so + it only needs the database to EXIST. + - fixture_cases_test needs dbo.Users (exact schema) and dbo.DropIndexes. + - rule_coverage_test builds its own Crap/CrapA/CrapB databases. + - no_access_test creates and drops its own login. + +No assertion depends on the row VALUES: reads are forced with WITH (INDEX =) +hints and every rule is structural (dedupe / overlap / usage-floor). The data +only needs the right schema, a unique Id (identity), populated NOT NULL +columns, and enough rows for indexes to carry real pages. EmailHash is left +all-NULL to match the real database, so the filtered EmailHash indexes behave +identically. + +This is test infrastructure, not shipped product code. dbo.DropIndexes is +Brent Ozar's helper from the Stack Overflow sample-database tooling, embedded +verbatim so the fixtures drop the same way they do on a real box. + +Safe to re-run: it drops and rebuilds StackOverflow2013 from scratch. Do not +point it at a database you care about. +============================================================================= +*/ + +USE master; +GO + +/* +Drop any existing copy first so the fixture is idempotent. +*/ +IF DB_ID(N'StackOverflow2013') IS NOT NULL +BEGIN + ALTER DATABASE StackOverflow2013 SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE StackOverflow2013; +END; +GO + +CREATE DATABASE StackOverflow2013; +GO + +/* +SIMPLE recovery keeps the log from bloating during the bulk load. +*/ +ALTER DATABASE StackOverflow2013 SET RECOVERY SIMPLE; +GO + +USE StackOverflow2013; +GO + +/* +The Users table, matching the real StackOverflow2013 schema exactly: +column order, types, nullability, and the clustered primary key on Id. +*/ +CREATE TABLE + dbo.Users +( + Id integer NOT NULL IDENTITY(1, 1), + AboutMe nvarchar(max) NULL, + Age integer NULL, + CreationDate datetime NOT NULL, + DisplayName nvarchar(40) NOT NULL, + DownVotes integer NOT NULL, + EmailHash nvarchar(40) NULL, + LastAccessDate datetime NOT NULL, + Location nvarchar(100) NULL, + Reputation integer NOT NULL, + UpVotes integer NOT NULL, + Views integer NOT NULL, + WebsiteUrl nvarchar(200) NULL, + AccountId integer NULL, + CONSTRAINT PK_Users_Id + PRIMARY KEY CLUSTERED (Id) +); +GO + +/* +Populate the table set-based from a tally. Values are deterministic and +varied across the columns the fixtures index and filter on (Reputation spans +the 1000/2000/10000 thresholds, CreationDate spans 2008-2013, DisplayName +varies its first letter for LIKE 'A%'/'B%'). EmailHash stays NULL to match +the real data. AboutMe stays NULL to keep the LOB column lean; the fixtures +only need it to exist as an includable column, not to carry data. + +Row count: 500k is a balance -- large enough that every index carries real +pages, small enough that fixture_cases_test's index builds and generated- +script executions stay fast in a container. Tune @row_target if needed. +*/ +DECLARE + @row_target integer = 500000; + +WITH + tally AS +( + SELECT TOP (@row_target) + n = + ROW_NUMBER() OVER + ( + ORDER BY + (SELECT NULL) + ) + FROM sys.all_columns AS a + CROSS JOIN sys.all_columns AS b +) +INSERT INTO + dbo.Users +WITH + (TABLOCK) +( + AboutMe, + Age, + CreationDate, + DisplayName, + DownVotes, + EmailHash, + LastAccessDate, + Location, + Reputation, + UpVotes, + Views, + WebsiteUrl, + AccountId +) +SELECT + AboutMe = CONVERT(nvarchar(max), NULL), + Age = + CASE + WHEN t.n % 7 = 0 + THEN NULL + ELSE 13 + (t.n % 80) + END, + CreationDate = DATEADD(DAY, t.n % 2000, CONVERT(datetime, N'20080731')), + DisplayName = NCHAR(65 + (t.n % 26)) + N'ser_' + CONVERT(nvarchar(20), t.n), + DownVotes = t.n % 50, + EmailHash = CONVERT(nvarchar(40), NULL), + LastAccessDate = DATEADD(DAY, t.n % 2000, CONVERT(datetime, N'20080801')), + Location = + CASE t.n % 5 + WHEN 0 THEN N'New York' + WHEN 1 THEN N'San Francisco' + WHEN 2 THEN N'London' + WHEN 3 THEN NULL + ELSE N'Seattle' + END, + Reputation = 1 + (t.n % 1000000), + UpVotes = t.n % 200, + Views = t.n % 1000, + WebsiteUrl = + CASE + WHEN t.n % 3 = 0 + THEN N'https://example.com/' + CONVERT(nvarchar(20), t.n) + ELSE NULL + END, + AccountId = t.n +FROM tally AS t +OPTION(MAXDOP 1); +GO + +/* +dbo.DropIndexes -- Brent Ozar's helper from the Stack Overflow sample-database +tooling, embedded verbatim (CREATE changed to CREATE OR ALTER). The fixtures +begin with EXECUTE dbo.DropIndexes to reset to a clean clustered-only Users +between runs. With its defaults it drops nonclustered indexes and unique +constraints but leaves the clustered primary key in place. +*/ +CREATE OR ALTER PROCEDURE + dbo.DropIndexes +( + @SchemaName sysname = 'dbo', + @TableName sysname = NULL, + @WhatToDrop varchar(10) = 'Everything', + @ExceptIndexNames nvarchar(MAX) = NULL, + @Debug bit = 'false' +) + AS +BEGIN + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + SET NOCOUNT ON; + SET STATISTICS XML OFF; + + CREATE TABLE + #commands + ( + ID integer IDENTITY PRIMARY KEY, + Command nvarchar(4000) NOT NULL + ); + CREATE TABLE + #errors + ( + ID integer IDENTITY PRIMARY KEY, + Command nvarchar(4000) NOT NULL, + ErrorMessage nvarchar(4000) NOT NULL + ); + + CREATE TABLE + #ExceptIndexNames + ( + IndexName nvarchar(4000) NOT NULL + ); + + INSERT INTO + #ExceptIndexNames + ( + IndexName + ) + SELECT DISTINCT + IndexName = UPPER(LTRIM(RTRIM(s.value))) + FROM STRING_SPLIT(@ExceptIndexNames, N',') AS s + WHERE s.value LIKE N'_%'; + + DECLARE + @CurrentCommand nvarchar(4000); + + IF + ( + UPPER(@WhatToDrop) LIKE 'C%' + OR UPPER(@WhatToDrop) LIKE 'E%' + ) + BEGIN + INSERT INTO + #commands + ( + Command + ) + SELECT + N'ALTER TABLE ' + + QUOTENAME(s.name) + + N'.' + + QUOTENAME(t.name) + + N' DROP CONSTRAINT ' + + QUOTENAME(o.name) + + N';' + FROM sys.objects AS o + JOIN sys.schemas AS s + ON o.schema_id = s.schema_id + JOIN sys.tables AS t + ON o.parent_object_id = t.object_id + WHERE o.type IN (N'C', N'F', N'UQ') + AND s.name = ISNULL(@SchemaName, s.name) COLLATE DATABASE_DEFAULT + AND t.name = ISNULL(@TableName, t.name) COLLATE DATABASE_DEFAULT + AND UPPER(o.name) NOT IN + ( + SELECT + e.IndexName COLLATE DATABASE_DEFAULT + FROM #ExceptIndexNames AS e + WHERE e.IndexName IS NOT NULL + ); + END; + + IF + ( + UPPER(@WhatToDrop) LIKE 'I%' + OR UPPER(@WhatToDrop) LIKE 'E%' + ) + BEGIN + INSERT INTO + #commands + ( + Command + ) + SELECT + N'DROP INDEX ' + + QUOTENAME(i.name) + + N' ON ' + + QUOTENAME(s.name) + + N'.' + + QUOTENAME(t.name) + + N';' + FROM sys.tables t + JOIN sys.schemas AS s + ON t.schema_id = s.schema_id + JOIN sys.indexes i + ON t.object_id = i.object_id + WHERE i.type NOT IN (0, 1, 5) + AND s.name = ISNULL(@SchemaName, s.name) COLLATE DATABASE_DEFAULT + AND t.name = ISNULL(@TableName, t.name) COLLATE DATABASE_DEFAULT + AND UPPER(i.name) NOT IN + ( + SELECT + e.IndexName COLLATE DATABASE_DEFAULT + FROM #ExceptIndexNames AS e + WHERE e.IndexName IS NOT NULL + ); + + INSERT INTO + #commands + ( + Command + ) + SELECT + N'DROP STATISTICS ' + + QUOTENAME(sc.name) + + N'.' + + QUOTENAME(t.name) + + N'.' + + QUOTENAME(s.name) + + N';' + FROM sys.stats AS s + JOIN sys.tables AS t + ON s.object_id = t.object_id + JOIN sys.schemas AS sc + ON t.schema_id = sc.schema_id + WHERE NOT EXISTS + ( + SELECT + 1/0 + FROM sys.indexes AS i + WHERE i.name = s.name + ) + AND sc.name = ISNULL(@SchemaName, s.name) + AND t.name = ISNULL(@TableName, t.name) + AND t.name NOT LIKE N'sys%'; + END; + IF @Debug = 1 + BEGIN + SELECT + c.* + FROM #commands AS c + ORDER BY + c.ID; + END; + + DECLARE + @result_cursor CURSOR + + SET @result_cursor = + CURSOR + LOCAL + SCROLL + DYNAMIC + READ_ONLY + FOR + SELECT + c.Command + FROM #commands AS c; + + OPEN @result_cursor; + + FETCH FIRST + FROM @result_cursor + INTO @CurrentCommand; + + WHILE @@FETCH_STATUS = 0 + BEGIN + BEGIN TRY + PRINT @CurrentCommand; + + EXECUTE sys.sp_executesql + @CurrentCommand; + END TRY + BEGIN CATCH + INSERT + #errors + ( + Command, + ErrorMessage + ) + VALUES + ( + @CurrentCommand, + ERROR_MESSAGE() + ); + END CATCH + + FETCH NEXT + FROM @result_cursor + INTO @CurrentCommand; + END; + IF EXISTS + ( + SELECT + 1/0 + FROM #errors AS e + ) + BEGIN + SELECT + e.ID, + e.Command, + e.ErrorMessage + FROM #errors AS e + ORDER BY + e.ID; + END; +END; +GO diff --git a/sp_IndexCleanup/tests/fixtures_dupe_indexes.sql b/sp_IndexCleanup/tests/fixtures_dupe_indexes.sql new file mode 100644 index 00000000..6dcdea02 --- /dev/null +++ b/sp_IndexCleanup/tests/fixtures_dupe_indexes.sql @@ -0,0 +1,143 @@ +/* +sp_IndexCleanup Test Fixtures - Duplicate Indexes on dbo.Users +============================================================== +Builds a broad set of edge-case indexes on the real StackOverflow2013.dbo.Users +table (~2.4 million rows), covering duplicates, subsets, sort directions, +filters, include merges, and never-used indexes. + +Unlike adversarial_test.sql, which builds small synthetic test_ic_* tables, this +runs against a real table with real data distribution and real sizes. + +Prerequisites: + - StackOverflow2013 database + - dbo.DropIndexes helper procedure (drops all nonclustered indexes) + +Order: + 1. fixtures_dupe_indexes.sql <-- you are here + 2. generate_index_reads.sql (so the indexes have usage stats) + 3. manual_test_runs.sql (execute sp_IndexCleanup various ways) + +WARNING: starts by dropping every nonclustered index on the database via +dbo.DropIndexes. Only run against a scratch copy of StackOverflow2013. +*/ +USE StackOverflow2013; +EXECUTE dbo.DropIndexes; +GO + +/* Baseline index - this is a good index that should be kept */ +CREATE INDEX IX_Users_Reputation +ON dbo.Users (Reputation DESC) +INCLUDE (DisplayName, CreationDate); + +/* Exact duplicate of the baseline index - should be identified as redundant */ +CREATE INDEX IX_Users_Reputation_Dup +ON dbo.Users (Reputation DESC) +INCLUDE (DisplayName, CreationDate); + +/* Subset of the baseline index - should be identified as redundant */ +CREATE INDEX IX_Users_Reputation_Subset +ON dbo.Users (Reputation DESC); + +/* Same leading column but different sort order - should be kept separate */ +CREATE INDEX IX_Users_Reputation_ASC +ON dbo.Users (Reputation ASC) +INCLUDE (DisplayName, CreationDate); + +/* Different column order - should be kept separate */ +CREATE INDEX IX_Users_DisplayName_Reputation +ON dbo.Users (DisplayName, Reputation); + +/* Subset of another index but different sort order - should be kept */ +CREATE INDEX IX_Users_DisplayName +ON dbo.Users (DisplayName); + +/* Index with overlapping columns - candidate for merging */ +CREATE INDEX IX_Users_CreationDate +ON dbo.Users (CreationDate) +INCLUDE (Reputation); + +/* Complementary index - candidate for merging */ +CREATE INDEX IX_Users_CreationDate_DisplayName +ON dbo.Users (CreationDate) +INCLUDE (DisplayName); + +/* Unique constraint index - should be kept */ +CREATE UNIQUE INDEX UQ_Users_EmailHash +ON dbo.Users (EmailHash) +WHERE EmailHash IS NOT NULL; + +/* Similar to unique index but not unique - should be identified as redundant */ +CREATE INDEX IX_Users_EmailHash +ON dbo.Users (EmailHash) +WHERE EmailHash IS NOT NULL; + +/* Filtered index - should be kept */ +CREATE INDEX IX_Users_Reputation_HighRep +ON dbo.Users (Reputation) +WHERE Reputation > 10000; + +/* Filtered index with same column but different filter - should be kept */ +CREATE INDEX IX_Users_Reputation_MediumRep +ON dbo.Users (Reputation) +WHERE Reputation >= 1000 AND Reputation <= 10000; + +/* Index with included columns that overlap with another index - mergeable */ +CREATE INDEX IX_Users_LastAccessDate +ON dbo.Users (LastAccessDate) +INCLUDE (Location, WebsiteUrl); + +/* Index with one additional include column - candidate to supersede the above */ +CREATE INDEX IX_Users_LastAccessDate_Extended +ON dbo.Users (LastAccessDate) +INCLUDE (Location, WebsiteUrl, Age); + +/* Index that will never be used - should be detected as unused */ +CREATE INDEX IX_Users_Unused +ON dbo.Users (DownVotes, UpVotes); + +/* Another unused index but with includes - should be detected as unused */ +CREATE INDEX IX_Users_Unused_WithIncludes +ON dbo.Users (Views) +INCLUDE (AccountId); + +/* Index that partially overlaps - might be kept */ +CREATE INDEX IX_Users_Location_Age +ON dbo.Users (Location, Age); + +/* Composite key index that could be useful - should be kept */ +CREATE INDEX IX_Users_AccountId_DisplayName +ON dbo.Users (AccountId, DisplayName); + +/* Another candidate for merging */ +CREATE INDEX IX_Users_AccountId_Reputation +ON dbo.Users (AccountId) +INCLUDE (Reputation); + +/* Same key columns in different order (Rule 7) */ +CREATE INDEX IX_Users_Age_Location +ON dbo.Users (Age, Location); + +/* Superset key with additional columns compared to IX_Users_Reputation */ +CREATE INDEX IX_Users_Reputation_CreationDate +ON dbo.Users (Reputation DESC, CreationDate) +INCLUDE (DisplayName); + +/* Similar index with more includes (extended) but without the naming convention */ +CREATE INDEX IX_Users_LastAccessDate_Full +ON dbo.Users (LastAccessDate) +INCLUDE (Location, WebsiteUrl, Age, DisplayName, Reputation); + +/* Index with exact same columns as IX_Users_DisplayName_Reputation but different order after first */ +CREATE INDEX IX_Users_DisplayName_RepAge +ON dbo.Users (DisplayName, Age, Reputation); + +/* Index with mismatched filter predicate that's a subset of another */ +CREATE INDEX IX_Users_Reputation_VeryHighRep +ON dbo.Users (Reputation) +WHERE Reputation > 50000; + +/* Index with mismatched filter predicate that's different */ +CREATE INDEX IX_Users_Reputation_Equals_One +ON dbo.Users (Reputation) +WHERE Reputation = 1; +GO diff --git a/sp_IndexCleanup/tests/fixtures_more_dupe_indexes.sql b/sp_IndexCleanup/tests/fixtures_more_dupe_indexes.sql new file mode 100644 index 00000000..a733f944 --- /dev/null +++ b/sp_IndexCleanup/tests/fixtures_more_dupe_indexes.sql @@ -0,0 +1,194 @@ +/* +sp_IndexCleanup Test Fixtures - Unique Constraint and Dedupe Rule Cases +======================================================================= +Builds a numbered set of dedupe cases on the real StackOverflow2013.dbo.Users +table, each annotated with the behavior it is supposed to produce. This is the +higher-value fixture set of the two: it is the only one that covers unique +CONSTRAINTS (cases 7a/7b/7c) alongside unique INDEXES (cases 3 and 5), and that +distinction drives real branches in the procedure. + +The "Expected:" comments are the specification. They earn their keep - two of +them caught real bugs that shipped and that neither compiling nor the +adversarial suite noticed: + + - Case 7c: the procedure used to emit a merge into the unique constraint + (CREATE INDEX ... WITH (DROP_EXISTING = ON) against a constraint-backed + index, which SQL Server rejects with Msg 1907) while still emitting a + runnable DISABLE of ix_c3 - losing that index's LastAccessDate include with + nothing absorbing it. + - Case 3: the procedure disabled ix_test_5 with uq_test_1 as the target but + never merged ix_test_5's include into it, silently dropping LastAccessDate + coverage. Unlike a constraint, a plain unique index does accept + DROP_EXISTING with an added INCLUDE, so the merge is legal and now happens. + +Nothing here asserts automatically. Verifying still means running sp_IndexCleanup +and reading the output against the comments. See README.md. + +Prerequisites: + - StackOverflow2013 database + - dbo.DropIndexes helper procedure + +WARNING: starts by dropping every nonclustered index on the database via +dbo.DropIndexes. Only run against a scratch copy of StackOverflow2013. +*/ +USE StackOverflow2013; +GO + +EXECUTE dbo.DropIndexes; +GO + +/* 1. Exact duplicates, not unique */ + +/* Test Case 1: Exact duplicates (same keys, same includes) */ +CREATE INDEX ix_test_1 ON dbo.Users(DisplayName) INCLUDE(Reputation); +CREATE INDEX ix_test_2 ON dbo.Users(DisplayName) INCLUDE(Reputation); +/* Expected: One index should be kept, other should be disabled */ + +/* 2. Key duplicates, not unique */ + +/* Test Case 2: Key duplicates with different includes */ +CREATE INDEX ix_test_3 ON dbo.Users(AccountId) INCLUDE(Reputation); +CREATE INDEX ix_test_4 ON dbo.Users(AccountId) INCLUDE(UpVotes); +/* Expected: One index should be kept with includes merged, other should be disabled */ + +/* 3. Key duplicates, one unique */ + +/* Test Case 3: Matching key columns, one unique */ +CREATE UNIQUE INDEX uq_test_1 ON dbo.Users(Location, Id); +CREATE INDEX ix_test_5 ON dbo.Users(Location, Id) INCLUDE(LastAccessDate); +/* Expected: Unique index should be kept, includes should be merged in */ + +/* 4. Superset/subset key columns */ + +/* Test Case 4: Superset/subset keys (not unique) */ +CREATE INDEX ix_test_6 ON dbo.Users(Age); +CREATE INDEX ix_test_7 ON dbo.Users(Age, CreationDate); +/* Expected: Wider index (Age, CreationDate) kept, narrower index disabled */ + +/* 5. Superset/subset with uniqueness */ + +/* Test Case 5: Superset/subset with unique narrower index */ +CREATE UNIQUE INDEX uq_test_2 ON dbo.Users(EmailHash, Id); +CREATE INDEX ix_test_8 ON dbo.Users(EmailHash, Id, WebsiteUrl); +/* Expected: Both kept (unique index should not be combined with wider non-unique index) */ + +/* 6. Mismatched key orders */ + +/* Test Case 6: Mismatched key orders */ +CREATE INDEX ix_test_9 ON dbo.Users(CreationDate, LastAccessDate, Views); +CREATE INDEX ix_test_10 ON dbo.Users(CreationDate, Views, LastAccessDate); +/* +Expected: Marked as "Same Keys Different Order" for review (Rule 8). This shows +up in the analysis report rows, not as a generated script, so look past the +script output when checking this one. +*/ + +/* 7. Unique constraint vs. nonclustered index */ + +/* Test Case 7a: Exact match between unique constraint and nonclustered index */ +ALTER TABLE dbo.Users ADD CONSTRAINT uq_test_c1 UNIQUE (DownVotes, Id); +CREATE INDEX ix_c1 ON dbo.Users(DownVotes, Id) INCLUDE(AboutMe); +/* Expected: Constraint disabled, index should be made unique */ + +/* Test Case 7b: Unique constraint vs. wider nonclustered index (should NOT match) */ +ALTER TABLE dbo.Users ADD CONSTRAINT uq_test_c2 UNIQUE (UpVotes, Id); +CREATE INDEX ix_c2 ON dbo.Users(UpVotes, Id, Reputation); +/* Expected: No action - keys don't match exactly */ + +/* Test Case 7c: Unique constraint wider than nonclustered index (should NOT match) */ +ALTER TABLE dbo.Users ADD CONSTRAINT uq_test_c3 UNIQUE (Id, DisplayName); +CREATE INDEX ix_c3 ON dbo.Users(Id) INCLUDE(LastAccessDate); +/* Expected: No action - keys don't match exactly */ + +/* 8. Testing Edge Cases */ + +/* Test Case 8a: Filtered indexes */ +CREATE INDEX ix_filtered_1 ON dbo.Users(Reputation) WHERE (Reputation > 1000); +CREATE INDEX ix_filtered_2 ON dbo.Users(Reputation) WHERE (Reputation > 1000); +/* Expected: One index kept, one disabled (matching filters) */ + +/* Test Case 8b: Filtered indexes with different filters (should NOT match) */ +CREATE INDEX ix_filtered_3 ON dbo.Users(Reputation) WHERE (Reputation > 2000); +/* Expected: Not matched with the above indexes due to different filter */ + +/* Test Case 8c: Descending key sort orders */ +CREATE INDEX ix_desc_1 ON dbo.Users(Reputation DESC); +CREATE INDEX ix_desc_2 ON dbo.Users(Reputation DESC); +/* Expected: One kept, one disabled (matching sort directions) */ + +/* Test Case 8d: Different sort directions (should NOT match) */ +CREATE INDEX ix_desc_3 ON dbo.Users(Reputation ASC); +/* Expected: Not matched with above indexes due to different sort direction */ +GO + +/* +Give every fixture index usage so none of them look never-used. + +The original working copy of this script drove reads with WHILE 1 = 1 loops meant +to be cancelled by hand. That is fine at a keyboard and a trap in a repository, +so this is bounded. Raise @iterations if a test needs bigger read counts - for +example when exercising @min_reads, which needs indexes on both sides of the +floor to be interesting. +*/ +DECLARE + @c bigint, + @i integer = 0, + @iterations integer = 50; + +WHILE @i < @iterations +BEGIN + /* Test Case 1: Exact duplicates */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_1) WHERE u.DisplayName LIKE 'A%' OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_2) WHERE u.DisplayName LIKE 'B%' OPTION(MAXDOP 1); + + /* Test Case 2: Key duplicates with different includes */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_3) WHERE u.AccountId > 1000 OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_4) WHERE u.AccountId < 5000 OPTION(MAXDOP 1); + + /* Test Case 3: Matching key columns, one unique */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_1) WHERE u.Location = 'New York' OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_5) WHERE u.Location LIKE 'San%' OPTION(MAXDOP 1); + + /* Test Case 4: Superset/subset keys */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_6) WHERE u.Age = 30 OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_7) WHERE u.Age = 30 AND u.CreationDate > '20100101' OPTION(MAXDOP 1); + + /* Test Case 5: Superset/subset with unique narrower index */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_2) WHERE u.EmailHash = 1 OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_8) WHERE u.EmailHash = 2 AND u.WebsiteUrl IS NOT NULL OPTION(MAXDOP 1); + + /* Test Case 6: Mismatched key orders */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_9) WHERE u.CreationDate > '20100101' AND u.LastAccessDate < '20200101' OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_test_10) WHERE u.CreationDate > '20100101' AND u.Views > 1000 OPTION(MAXDOP 1); + + /* Test Case 7a: Unique constraint and its exact-match nonclustered index */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_c1) WHERE u.DownVotes = 10 OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_c1) WHERE u.DownVotes = 20 OPTION(MAXDOP 1); + + /* Test Case 7b: Unique constraint vs. wider nonclustered index */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_c2) WHERE u.UpVotes = 100 OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_c2) WHERE u.UpVotes = 200 AND u.Reputation > 1000 OPTION(MAXDOP 1); + + /* Test Case 7c: Unique constraint wider than nonclustered index */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = uq_test_c3) WHERE u.DisplayName = 'A' OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_c3) WHERE u.Id = 2 OPTION(MAXDOP 1); + + /* Test Case 8a/8b: Filtered indexes */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_filtered_1) WHERE u.Reputation > 1000 OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_filtered_2) WHERE u.Reputation > 1000 OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_filtered_3) WHERE u.Reputation > 2000 OPTION(MAXDOP 1); + + /* Test Case 8c/8d: Sort directions */ + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_desc_1) OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_desc_2) OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.Users AS u WITH (INDEX = ix_desc_3) OPTION(MAXDOP 1); + + SELECT @i += 1; +END; +GO + +/* Now look at what the procedure makes of them */ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @dedupe_only = 1; +GO diff --git a/sp_IndexCleanup/tests/generate_index_reads.sql b/sp_IndexCleanup/tests/generate_index_reads.sql new file mode 100644 index 00000000..812c547f --- /dev/null +++ b/sp_IndexCleanup/tests/generate_index_reads.sql @@ -0,0 +1,172 @@ +/* +sp_IndexCleanup Test Fixtures - Generate Index Usage +==================================================== +Drives reads through each index built by fixtures_dupe_indexes.sql so +sys.dm_db_index_usage_stats has something to report. Without this every index +looks never-used and the usage-based rules and filters have nothing to work on. + +Each index gets one read per iteration, so after the default 100 iterations +every hinted index carries ~100 reads and the object total is ~2000. That +matters when testing @min_reads: the object-level screen sums usage across all +indexes on the table, while the index-level screen compares each index on its +own. + +IX_Users_Unused and IX_Users_Unused_WithIncludes are deliberately left out so +the unused-index rules have something to find. + +Prerequisites: run fixtures_dupe_indexes.sql first. + +Runtime: roughly 2 minutes against StackOverflow2013 on a local instance. +*/ +USE StackOverflow2013; +GO + +DECLARE + @i integer = 0, + @c bigint = 0; + +WHILE + @i < 100 +BEGIN + /* Generate reads for IX_Users_Reputation */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Reputation) + WHERE Reputation > 1000 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_Reputation_ASC */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Reputation_ASC) + WHERE Reputation < 1000 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_DisplayName_Reputation */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_DisplayName_Reputation) + WHERE DisplayName LIKE 'A%' AND Reputation > 5000 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_DisplayName */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_DisplayName) + WHERE DisplayName LIKE 'B%' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_CreationDate */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_CreationDate) + WHERE CreationDate > '20100101' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_CreationDate_DisplayName */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_CreationDate_DisplayName) + WHERE CreationDate > '20120101' + OPTION (MAXDOP 1); + + /* Generate reads for UQ_Users_EmailHash */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = UQ_Users_EmailHash) + WHERE EmailHash = 123456 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_Reputation_HighRep */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Reputation_HighRep) + WHERE Reputation > 50000 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_Reputation_MediumRep */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Reputation_MediumRep) + WHERE Reputation = 5000 + OPTION (MAXDOP 1); + + /* + Generate reads for IX_Users_LastAccessDate. StackOverflow2013 data stops in + 2013, so these later date predicates match nothing - that is fine, the seek + still registers as a read, which is all this needs. + */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_LastAccessDate) + WHERE LastAccessDate > '20200101' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_LastAccessDate_Extended */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_LastAccessDate_Extended) + WHERE LastAccessDate > '20210101' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_Location_Age */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Location_Age) + WHERE Location LIKE '%United States%' AND Age > 30 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_AccountId_DisplayName */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_AccountId_DisplayName) + WHERE AccountId = 1234 AND DisplayName LIKE 'C%' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_AccountId_Reputation */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_AccountId_Reputation) + WHERE AccountId = 5678 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_Age_Location */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Age_Location) + WHERE Age > 25 AND Location LIKE 'S%' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_Reputation_CreationDate */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Reputation_CreationDate) + WHERE Reputation > 5000 AND CreationDate > '20190101' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_LastAccessDate_Full */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_LastAccessDate_Full) + WHERE LastAccessDate > '20230101' + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_DisplayName_RepAge */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_DisplayName_RepAge) + WHERE DisplayName LIKE 'D%' AND Age > 40 + OPTION (MAXDOP 1); + + /* Generate reads for IX_Users_Reputation_VeryHighRep */ + SELECT @c = COUNT_BIG(*) + FROM dbo.Users WITH (INDEX = IX_Users_Reputation_VeryHighRep) + WHERE Reputation > 75000 + OPTION (MAXDOP 1); + + SELECT @i += 1; + + IF @i % 10 = 0 + BEGIN + RAISERROR('iteration %d', 0, 0, @i) WITH NOWAIT; + END; +END; +GO + +/* Confirm the indexes actually accumulated usage */ +SELECT + index_name = i.name, + reads = ius.user_seeks + ius.user_scans + ius.user_lookups, + writes = ius.user_updates +FROM sys.indexes AS i +LEFT JOIN sys.dm_db_index_usage_stats AS ius + ON ius.object_id = i.object_id + AND ius.index_id = i.index_id + AND ius.database_id = DB_ID() +WHERE i.object_id = OBJECT_ID(N'dbo.Users') +AND i.index_id > 1 +ORDER BY + reads DESC; +GO diff --git a/sp_IndexCleanup/tests/manual_test_runs.sql b/sp_IndexCleanup/tests/manual_test_runs.sql new file mode 100644 index 00000000..07259d7f --- /dev/null +++ b/sp_IndexCleanup/tests/manual_test_runs.sql @@ -0,0 +1,76 @@ +/* +sp_IndexCleanup Manual Test Runs +================================= +Assorted ways to invoke the procedure once fixtures are in place. These are for +eyeballing output, not assertions - the automated checks live in run_tests.py. + +Prerequisites: + 1. fixtures_dupe_indexes.sql + 2. generate_index_reads.sql + +Then run these individually rather than as one batch: most produce large result +sets, and @debug = 1 produces a lot more. +*/ + +/* Basic execution for a database */ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @debug = 1; + +/* Target a specific schema and table */ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @schema_name = 'dbo', + @table_name = 'Users', + @debug = 0; + +/* +Only consider indexes with minimum usage and size. + +Both floors are applied twice: once per object (a table qualifies when its +indexes together clear a floor) and once per index (an individual index is +dropped from the analysis when it misses on its own). An index counts as used +when it clears EITHER floor. + +With generate_index_reads.sql's default 100 iterations each index carries ~100 +reads, so @min_reads = 1000 screens all of them out and the run comes back +empty. Lower the floor or raise the iteration count to see it filter partially, +which is the interesting case. +*/ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @min_reads = 1000, /* Only analyze indexes with at least 1000 reads */ + @min_writes = 500, /* Only analyze indexes with at least 500 writes */ + @min_size_gb = 0.01, /* Only analyze indexes at least 10MB in size */ + @min_rows = 10000, /* Only analyze tables with at least 10,000 rows */ + @debug = 0; + +/* Same filters, with the diagnostic output */ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @min_reads = 1000, + @min_writes = 500, + @min_size_gb = 0.01, + @min_rows = 10000, + @debug = 1; + +/* A floor low enough to keep some indexes and drop others */ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @min_reads = 50, + @debug = 0; + +/* Deduplication rules only, skipping compression analysis */ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @dedupe_only = 1, + @debug = 0; + +/* Production run without debug output */ +EXECUTE dbo.sp_IndexCleanup + @database_name = 'StackOverflow2013', + @debug = 0; + +/* View help documentation */ +EXECUTE dbo.sp_IndexCleanup + @help = 1; diff --git a/sp_IndexCleanup/tests/no_access_test.py b/sp_IndexCleanup/tests/no_access_test.py index 9385dce4..7745f8a5 100644 --- a/sp_IndexCleanup/tests/no_access_test.py +++ b/sp_IndexCleanup/tests/no_access_test.py @@ -21,11 +21,25 @@ python no_access_test.py [--server SQL2022] [--password "L!nt0044"] """ +import os +import shlex import subprocess import sys import time import secrets + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment + so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to + 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the + go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + container's self-signed cert and disable encryption, which the modern Go + TLS stack needs to connect to the SQL Server 2017 container.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + TEST_LOGIN = f"sp_indexcleanup_no_access_test_{secrets.token_hex(4)}" TEST_PASSWORD = "T3st!" + secrets.token_hex(8) + "Aa1" TIMEOUT_SECONDS = 30 @@ -34,7 +48,8 @@ def run_sqlcmd_as_sa(server, sa_password, sql): """Execute a SQL statement as sa.""" cmd = [ - "sqlcmd", "-S", server, "-U", "sa", "-P", sa_password, + *_sqlcmd_prefix(), + "-S", server, "-U", "sa", "-P", sa_password, "-d", "master", "-b", "-Q", sql, ] result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) @@ -46,7 +61,8 @@ def run_sqlcmd_as_sa(server, sa_password, sql): def run_sqlcmd_as_test_login(server, sql, timeout): """Execute a SQL statement as the test login, with a wall-clock timeout.""" cmd = [ - "sqlcmd", "-S", server, "-U", TEST_LOGIN, "-P", TEST_PASSWORD, + *_sqlcmd_prefix(), + "-S", server, "-U", TEST_LOGIN, "-P", TEST_PASSWORD, "-d", "master", "-Q", sql, ] start = time.monotonic() diff --git a/sp_IndexCleanup/tests/rule_coverage_test.py b/sp_IndexCleanup/tests/rule_coverage_test.py new file mode 100644 index 00000000..fa88674c --- /dev/null +++ b/sp_IndexCleanup/tests/rule_coverage_test.py @@ -0,0 +1,1641 @@ +""" +sp_IndexCleanup Rule Coverage Tests +=================================== +Covers things nothing else in this directory tests: Rule 1 (unused index +detection), the @min_reads / @min_writes index-level screen, and four ways the +procedure can emit a script that is wrong. + +The ranking that decides what is worth asserting here: this procedure emits DDL +that people run against production. A script that ERRORS is nearly harmless -- +it stops, and nobody's data moves. A script that SUCCEEDS while being wrong is +the real hazard, because there is nothing to notice. Groups C, D, E and F all +target that class, so they assert on the script text the procedure produces +rather than on whether some execution raised an error. + +Every absence assertion below is paired with a positive control. "No DISABLE +script named the PK" is trivially true if the PK was never analyzed, so each +group first proves the index was in play. + +Builds its own synthetic fixture in the Crap database -- three ~20,000 row tables, +scoped with @table_name so each procedure run is fast. It deliberately does not +touch StackOverflow2013: fixture_cases_test.py already spends two minutes there, +and this one is meant to be quick. Drops its tables afterward. + +Group F additionally builds two throwaway databases, because the defect it +covers only exists across the database loop and cannot be reproduced inside a +single database. They are dropped unconditionally afterward, including when an +assertion fails or the fixture itself does not build. + +(a) Rule 1, unused index detection. + + The procedure auto-enables @dedupe_only when server uptime is <= 7 days, and + Rule 1 only runs when @dedupe_only = 0. So on a recently restarted instance + Rule 1 cannot be reached through the public interface at all. That is correct, + deliberate behavior: usage data on a freshly started instance is worthless, + and recommending index drops from it would be actively harmful. + + Rather than skip, this asserts whichever statement is true on the instance in + front of it: + + - uptime > 7 days: the never-read index gets a DISABLE with a consolidation + rule of 'Unused Index'. + - uptime <= 7 days: the GUARD holds. @dedupe_only was auto-enabled (proven + by the procedure's own @debug message) and no 'Unused + Index' rows appear anywhere. + + The guard branch is a real assertion, not a silent pass. It is kept honest by + positive controls: the harness first proves the index was analyzed (it appears + in the output) and that it genuinely has zero reads, so the absence of an + 'Unused Index' row is the guard doing its job rather than the index being + invisible for some unrelated reason. + +(b) The @min_reads / @min_writes index-level screen. + + The screen gates DEDUPE ONLY. It must not take compression recommendations + with it -- an index can be far too cold to bother deduping and still be worth + compressing. It is also an OR: an index clears by meeting EITHER floor. + + The write-floor cases run against a table whose clustered PK carries reads + (an UPDATE seeks it) while ix_w1/ix_w2 have none. The table therefore clears + the object-level filter on its own, which means these cases exercise the + index-level screen specifically rather than the coarser per-table filter. + +(c) Nonclustered PRIMARY KEYs are constraint-backed. + + is_primary_key is not is_unique_constraint: the latter is 0 for a primary + key, so a rule that guards only on is_unique_constraint leaves PKs exposed. + A nonclustered PK is type = 2, which is also why it used to look like an + ordinary unique index to the dedupe rules. + + Two proven consequences, both fixed: + - a key duplicate earned the PK a MERGE (CREATE UNIQUE INDEX ... + DROP_EXISTING against a constraint -> Msg 1907) while the paired DISABLE + of the other index succeeded, silently dropping a covering column. + - an exact duplicate with tied priorities put the PK on the losing side of + an alphabetical tiebreak and emitted ALTER INDEX [pk...] DISABLE. That + one does not error. It succeeds, disables every inbound foreign key, and + orphan rows insert cleanly afterward. + + The compression positive controls are the other half: the fix must not work + by making primary keys invisible. + +(d) Sort direction is part of an index's identity. + + Rule 2 and Rule 3 each carry an explicit is_descending_key comparison. + Rule 5 carries none -- the ' DESC' embedded into key_columns is the only + thing keeping (col_a, col_b) and (col_a, col_b DESC) from hashing alike. + Without it they become a false Key Duplicate whose DISABLE runs cleanly. + +(e) A merged index may not INCLUDE its own key column. + + Rule 6 merges a subset's includes into the superset absorbing it. If the + subset's include is already a key of the superset, the emitted CREATE INDEX + lists the column twice (Msg 1909) while the subset's DISABLE still runs. + +(f) #index_details is per-database. #index_analysis is not. + + Under @get_all_databases the procedure TRUNCATES #index_details for each + database in the loop, while #index_analysis ACCUMULATES across all of them + to build the final output. Every rule therefore re-runs over rows belonging + to databases that were already processed. + + Rules 2/3/5/7 survive that because of their shape: each requires + EXISTS (#index_details ... is_eligible_for_dedupe = 1), which finds nothing + for a stale row and so fails CLOSED. Rule 7.6 carried only a + NOT EXISTS (... is_unique_constraint = 1), and a NOT EXISTS against an + emptied table passes VACUOUSLY. On the second database's pass it therefore + paired the FIRST database's leftover MAKE UNIQUE winner with that database's + primary key and marked the PK DISABLE. The script-generation backstops read + #index_details too, so they passed vacuously in turn and this shipped: + + ALTER INDEX [pk_mdb] ON [CrapA].[dbo].[mdb_test] DISABLE; + + It executes cleanly, silently disables every inbound foreign key, and orphan + rows then insert with no error. A NOT EXISTS fails open on a stale row where + an EXISTS fails closed, which is the whole reason the guard has that shape. + + This is the only group that needs more than one database, so it builds two. + The defect IS the loop, and a single-database fixture cannot express it. + + The positive controls carry more weight here than anywhere else in this file, + because every assertion is an assertion of absence and the bug has four + preconditions -- CrapA processed first, CrapA's table analyzed, Rule 7.5 + actually producing the MAKE UNIQUE winner, and CrapB reached so a second + iteration happens. If any one of them silently stopped being true, every + assertion below would go green while testing nothing at all. + +Usage: + python rule_coverage_test.py [--server SQL2022] [--password "L!nt0044"] +""" + +import os +import re +import shlex +import subprocess +import sys +import tempfile + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment + so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to + 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the + go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + container's self-signed cert and disable encryption, which the modern Go + TLS stack needs to connect to the SQL Server 2017 container.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + +TEST_DATABASE = "Crap" + +# Group F's two throwaway databases. CrapA is processed FIRST and is the one +# whose leftover rows go stale during CrapB's pass, so CrapA owns the primary +# key the bug used to disable. See MDB_SETUP_SQL for why the order is by +# creation and not by name. +MDB_DATABASE_FIRST = "CrapA" +MDB_DATABASE_SECOND = "CrapB" +MDB_TABLE = "mdb_test" +MDB_PK = "pk_mdb" + +# Group G reuses the very same CrapA/CrapB (CrapA processed first is exactly the +# property both groups need) but adds two of its own tables. The two defects it +# covers live in SEPARATE tables analyzed in SEPARATE @table_name scoped runs on +# purpose: with both in one table the unique-constraint rules reshuffle the merge +# dedup and the include strip stops being deterministic. See MDB_G_SETUP_SQL. +MDB_MERGE_TABLE = "icg_merge" # superset/subset include-merge pair (bug 1) +MDB_MERGE_WINNER = "ix_merge_super" # the Key Superset that must keep its INCLUDE +MDB_MERGE_SUBSET = "ix_merge_sub" # the subset it absorbs and disables +MDB_UC_TABLE = "icg_uc" # unique constraint + same-key index (bug 2) +MDB_UC_CONSTRAINT = "uq_icg" # the constraint that must not get ALTER..DISABLE +MDB_UC_PLAIN = "ix_uc_plain" # the index promoted to replace it + +# Rows that represent a dedupe action. A COMPRESSION SCRIPT row is not one. +DEDUPE_SCRIPT_TYPES = ("DISABLE SCRIPT", "MERGE SCRIPT", "DISABLE CONSTRAINT SCRIPT") + +GUARD_MESSAGE = "Automatically enabling @dedupe_only mode" + +SETUP_SQL = """ +SET NOCOUNT ON; + +DROP TABLE IF EXISTS dbo.ic_min_reads_test; +DROP TABLE IF EXISTS dbo.ic_min_writes_test; +DROP TABLE IF EXISTS dbo.ic_unused_test; +DROP TABLE IF EXISTS dbo.ic_pk_key_test; +DROP TABLE IF EXISTS dbo.ic_pk_exact_test; +DROP TABLE IF EXISTS dbo.ic_sort_dir_test; +DROP TABLE IF EXISTS dbo.ic_key_include_test; +GO + +/* +ic_min_reads_test: ix_hot (~200 reads), ix_warm (~5 reads, a key duplicate of +ix_hot with different includes so the two pair), clustered PK (0 reads). +*/ +CREATE TABLE + dbo.ic_min_reads_test +( + id integer NOT NULL, + col_a integer NOT NULL, + col_b integer NOT NULL, + col_c integer NOT NULL, + filler varchar(100) NOT NULL, + CONSTRAINT pk_ic_min_reads_test PRIMARY KEY CLUSTERED (id) +); + +/* +ic_min_writes_test: ix_w1 / ix_w2 are a key-duplicate pair that is never read but +is written to. The clustered PK picks up reads from the UPDATE seeks, which lets +the table clear the object-level filter so the index-level screen is what gets +tested. +*/ +CREATE TABLE + dbo.ic_min_writes_test +( + id integer NOT NULL, + col_a integer NOT NULL, + col_b integer NOT NULL, + col_c integer NOT NULL, + filler varchar(100) NOT NULL, + CONSTRAINT pk_ic_min_writes_test PRIMARY KEY CLUSTERED (id) +); + +/* +ic_unused_test: ix_never_read has distinct keys so no dedupe rule can fire on it. +The only thing that can flag it is Rule 1. +*/ +CREATE TABLE + dbo.ic_unused_test +( + id integer NOT NULL, + col_a integer NOT NULL, + col_b integer NOT NULL, + filler varchar(100) NOT NULL, + CONSTRAINT pk_ic_unused_test PRIMARY KEY CLUSTERED (id) +); + +/* +ic_pk_key_test: a NONCLUSTERED primary key with a key duplicate next to it. +Same keys, different includes, so Rule 5 pairs them. The PK is unique and the +plain index is not, which makes the PK the "winner" and earns it MERGE +INCLUDES -> CREATE UNIQUE INDEX ... WITH (DROP_EXISTING = ON) against a +constraint-backed index. That fails with Msg 1907 while the paired DISABLE of +ix_pk_key_dupe succeeds, silently dropping col_b coverage. + +is_primary_key is NOT is_unique_constraint: it is 0 for a primary key, so every +rule that guards only on is_unique_constraint leaves a PK exposed. +*/ +CREATE TABLE + dbo.ic_pk_key_test +( + id integer NOT NULL, + col_b integer NOT NULL, + filler varchar(100) NOT NULL, + CONSTRAINT pk_ic_pk_key_test PRIMARY KEY NONCLUSTERED (id) +); + +/* +ic_pk_exact_test: a NONCLUSTERED primary key with an exact duplicate. + +The names matter. Rule 2 breaks a priority tie alphabetically and disables the +LATER name, so the PK is deliberately named to sort after the plain index. Both +indexes are given exactly one seek below so their priorities genuinely tie +(500 unique + 200 seeks) and the tiebreak is what decides -- without that the +PK wins on its own and the bug never shows. + +ALTER INDEX ... DISABLE against a PK does not error. It succeeds, disables every +inbound foreign key with it, and orphan rows then insert cleanly. A script that +succeeds while being wrong is the worst thing this procedure can emit, which is +why this asserts on the script text rather than on an execution error. +*/ +CREATE TABLE + dbo.ic_pk_exact_test +( + id integer NOT NULL, + filler varchar(100) NOT NULL, + CONSTRAINT zz_pk_ic_pk_exact_test PRIMARY KEY NONCLUSTERED (id) +); + +/* +ic_sort_dir_test: two indexes identical but for the sort direction of the +second key column, with different includes so they land in Rule 5 rather than +Rule 2. + +Rule 2 and Rule 3 each carry an explicit is_descending_key comparison. Rule 5 +carries NONE: its only defense is that key_columns embeds ' DESC' into the +string the key_filter_hash is built from. Delete that embedding and these two +become a false Key Duplicate -- the DESC index gets a cleanly-executing DISABLE +and the survivor is ASC-only, so every query depending on the DESC ordering +silently regresses. Nothing errors. + +Both indexes are given reads so Rule 1 can never claim either on a +long-uptime server, which would make the assertions below pass vacuously. +ix_sort_asc gets seeks AND scans (200 + 100) while ix_sort_desc gets seeks +only (200), so priorities differ and the loser is deterministic. +*/ +CREATE TABLE + dbo.ic_sort_dir_test +( + id integer NOT NULL, + col_a integer NOT NULL, + col_b integer NOT NULL, + col_c integer NOT NULL, + col_d integer NOT NULL, + filler varchar(100) NOT NULL, + CONSTRAINT pk_ic_sort_dir_test PRIMARY KEY CLUSTERED (id) +); + +/* +ic_key_include_test: a key subset whose include is already a key column of the +superset that absorbs it. + +Rule 3 disables ix_ki_subset (col_a) in favor of ix_ki_superset (col_a, col_b), +and Rule 6 merges the subset's includes into the superset. col_b is already in +the superset's KEY, and SQL Server rejects an index that includes its own key +column with Msg 1909. Rule 6's key-column exclusion is the only thing that +keeps col_b out of the INCLUDE list. +*/ +CREATE TABLE + dbo.ic_key_include_test +( + id integer NOT NULL, + col_a integer NOT NULL, + col_b integer NOT NULL, + filler varchar(100) NOT NULL, + CONSTRAINT pk_ic_key_include_test PRIMARY KEY CLUSTERED (id) +); +GO + +INSERT INTO + dbo.ic_min_reads_test +( + id, + col_a, + col_b, + col_c, + filler +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500, + col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100, + col_c = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 250, + filler = REPLICATE('x', 100) +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +INSERT INTO + dbo.ic_min_writes_test +( + id, + col_a, + col_b, + col_c, + filler +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500, + col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100, + col_c = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 250, + filler = REPLICATE('x', 100) +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +INSERT INTO + dbo.ic_unused_test +( + id, + col_a, + col_b, + filler +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500, + col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100, + filler = REPLICATE('x', 100) +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +INSERT INTO + dbo.ic_pk_key_test +( + id, + col_b, + filler +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100, + filler = REPLICATE('x', 100) +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +INSERT INTO + dbo.ic_pk_exact_test +( + id, + filler +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + filler = REPLICATE('x', 100) +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +INSERT INTO + dbo.ic_sort_dir_test +( + id, + col_a, + col_b, + col_c, + col_d, + filler +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500, + col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100, + col_c = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 250, + col_d = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 50, + filler = REPLICATE('x', 100) +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +INSERT INTO + dbo.ic_key_include_test +( + id, + col_a, + col_b, + filler +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 500, + col_b = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) % 100, + filler = REPLICATE('x', 100) +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); +GO + +CREATE INDEX ix_hot ON dbo.ic_min_reads_test (col_a) INCLUDE (col_b); +CREATE INDEX ix_warm ON dbo.ic_min_reads_test (col_a) INCLUDE (col_c); + +CREATE INDEX ix_w1 ON dbo.ic_min_writes_test (col_a) INCLUDE (col_b); +CREATE INDEX ix_w2 ON dbo.ic_min_writes_test (col_a) INCLUDE (col_c); + +CREATE INDEX ix_never_read ON dbo.ic_unused_test (col_a); + +/* Key duplicate of the nonclustered PK: same key, different includes -> Rule 5 */ +CREATE INDEX ix_pk_key_dupe ON dbo.ic_pk_key_test (id) INCLUDE (col_b); + +/* Exact duplicate of the nonclustered PK, named to sort BEFORE it */ +CREATE UNIQUE INDEX aa_ix_pk_exact_dupe ON dbo.ic_pk_exact_test (id); + +/* Identical but for the direction of col_b, and with different includes */ +CREATE INDEX ix_sort_asc ON dbo.ic_sort_dir_test (col_a, col_b) INCLUDE (col_c); +CREATE INDEX ix_sort_desc ON dbo.ic_sort_dir_test (col_a, col_b DESC) INCLUDE (col_d); + +/* Subset whose include (col_b) is already a key column of the superset */ +CREATE INDEX ix_ki_subset ON dbo.ic_key_include_test (col_a) INCLUDE (col_b); +CREATE INDEX ix_ki_superset ON dbo.ic_key_include_test (col_a, col_b); +GO + +/* +Drive reads so no index below is left cold enough for Rule 1 to claim it as +unused on a long-uptime server, which would make the absence assertions pass +for the wrong reason. + +ix_sort_asc gets seeks AND scans (+200 +100) while ix_sort_desc gets seeks only +(+200). That gap is what makes ix_sort_desc the deterministic loser if the two +ever pair up, rather than leaving a priority tie to resolve arbitrarily. +*/ +DECLARE + @c bigint, + @i integer = 0; + +WHILE @i < 10 +BEGIN + /* PK key-duplicate pair: both sides read */ + SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_key_test AS t WITH (INDEX = pk_ic_pk_key_test) WHERE t.id = @i OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_key_test AS t WITH (INDEX = ix_pk_key_dupe) WHERE t.id = @i OPTION(MAXDOP 1); + + /* PK exact-duplicate pair: one seek each, so index_priority ties */ + SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_exact_test AS t WITH (INDEX = zz_pk_ic_pk_exact_test) WHERE t.id = @i OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.ic_pk_exact_test AS t WITH (INDEX = aa_ix_pk_exact_dupe) WHERE t.id = @i OPTION(MAXDOP 1); + + /* Sort-direction pair: seeks on both */ + SELECT @c = COUNT_BIG(*) FROM dbo.ic_sort_dir_test AS t WITH (INDEX = ix_sort_asc) WHERE t.col_a = @i OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.ic_sort_dir_test AS t WITH (INDEX = ix_sort_desc) WHERE t.col_a = @i OPTION(MAXDOP 1); + + /* Key-subset/superset pair: reads on both */ + SELECT @c = COUNT_BIG(*) FROM dbo.ic_key_include_test AS t WITH (INDEX = ix_ki_subset) WHERE t.col_a = @i OPTION(MAXDOP 1); + SELECT @c = COUNT_BIG(*) FROM dbo.ic_key_include_test AS t WITH (INDEX = ix_ki_superset) WHERE t.col_a = @i OPTION(MAXDOP 1); + + SELECT @i += 1; +END; + +/* Scans for ix_sort_asc only, so it outranks ix_sort_desc */ +SELECT @i = 0; + +WHILE @i < 3 +BEGIN + SELECT @c = COUNT_BIG(*) FROM dbo.ic_sort_dir_test AS t WITH (INDEX = ix_sort_asc) WHERE t.col_c >= 0 OPTION(MAXDOP 1); + SELECT @i += 1; +END; +GO + +/* +Drive reads with forced index hints, bounded: ix_hot ~200, ix_warm ~5, PK 0. +Nothing reads ic_unused_test at all. +*/ +DECLARE + @c bigint, + @i integer = 0; + +WHILE @i < 200 +BEGIN + SELECT @c = COUNT_BIG(*) FROM dbo.ic_min_reads_test AS t WITH (INDEX = ix_hot) WHERE t.col_a = @i % 500 OPTION(MAXDOP 1); + SELECT @i += 1; +END; + +SELECT @i = 0; + +WHILE @i < 5 +BEGIN + SELECT @c = COUNT_BIG(*) FROM dbo.ic_min_reads_test AS t WITH (INDEX = ix_warm) WHERE t.col_a = @i % 500 OPTION(MAXDOP 1); + SELECT @i += 1; +END; +GO + +/* +Drive writes on ic_min_writes_test. Updating col_a maintains both ix_w1 and ix_w2 +(it is the key of both), so both collect writes while collecting no reads. +*/ +DECLARE + @i integer = 0; + +WHILE @i < 60 +BEGIN + UPDATE + t + SET + t.col_a = (t.col_a + 1) % 500 + FROM dbo.ic_min_writes_test AS t + WHERE t.id = @i + OPTION(MAXDOP 1); + + SELECT @i += 1; +END; +GO +""" + +CLEANUP_SQL = """ +SET NOCOUNT ON; +DROP TABLE IF EXISTS dbo.ic_min_reads_test; +DROP TABLE IF EXISTS dbo.ic_min_writes_test; +DROP TABLE IF EXISTS dbo.ic_unused_test; +DROP TABLE IF EXISTS dbo.ic_pk_key_test; +DROP TABLE IF EXISTS dbo.ic_pk_exact_test; +DROP TABLE IF EXISTS dbo.ic_sort_dir_test; +DROP TABLE IF EXISTS dbo.ic_key_include_test; +""" + +# Group F's fixture. Unlike everything above it, this one needs two databases: +# the defect only exists across iterations of the @get_all_databases loop. +# +# CrapA MUST be created before CrapB. The procedure's database cursor is +# ORDER BY database_id, NOT by name, so processing order is CREATION order and +# the names are a convenience rather than the mechanism. CrapA therefore has to +# be the first database processed, so that its rows are the stale ones still +# sitting in #index_analysis when CrapB's pass truncates #index_details out from +# under them. run_tests asserts that this actually held rather than trusting it. +# +# CrapA gets PRIMARY KEY NONCLUSTERED (col_a) + UNIQUE (col_a) + ix_mdb (col_a). +# The unique constraint is what gives Rule 7.5 something to replace, which is +# what leaves a MAKE UNIQUE winner (ix_mdb) behind in #index_analysis. Without it +# there is no winner for the stale PK to pair with and the bug cannot fire. +# +# CrapB only has to exist, contain the table so the loop does real work for it, +# and be processed after CrapA. Its own indexes are uninteresting; the second +# iteration itself is the point. +# +# Both databases are pre-dropped so a run that died before its cleanup cannot +# leave a half-built fixture that quietly changes what is being tested. +MDB_SETUP_SQL = """ +SET NOCOUNT ON; +GO + +IF DB_ID('%(first)s') IS NOT NULL +BEGIN + ALTER DATABASE %(first)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE %(first)s; +END; +GO + +IF DB_ID('%(second)s') IS NOT NULL +BEGIN + ALTER DATABASE %(second)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE %(second)s; +END; +GO + +/* Creation order is processing order. This one has to come first. */ +CREATE DATABASE %(first)s; +GO + +CREATE DATABASE %(second)s; +GO + +USE %(first)s; +GO + +/* +The unique constraint is the load-bearing part: Rule 7.5 replaces it with ix_mdb +and marks ix_mdb MAKE UNIQUE, and that leftover winner is what the stale primary +key used to get paired against on the next database's pass. +*/ +CREATE TABLE + dbo.mdb_test +( + col_a integer NOT NULL, + col_b integer NOT NULL, + CONSTRAINT pk_mdb PRIMARY KEY NONCLUSTERED (col_a), + CONSTRAINT uq_mdb UNIQUE (col_a) +); + +INSERT INTO + dbo.mdb_test +( + col_a, + col_b +) +SELECT TOP (20000) + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_b = 1 +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +CREATE INDEX ix_mdb ON dbo.mdb_test (col_a); +GO + +USE %(second)s; +GO + +/* +Deliberately plain. All this database has to do is exist and be processed after +CrapA, so that a second loop iteration happens at all. +*/ +CREATE TABLE + dbo.mdb_test +( + col_a integer NOT NULL, + col_b integer NOT NULL +); + +INSERT INTO + dbo.mdb_test +( + col_a, + col_b +) +SELECT TOP (20000) + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_b = 1 +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +CREATE INDEX ix_mdb_b ON dbo.mdb_test (col_a); +GO +""" % {"first": MDB_DATABASE_FIRST, "second": MDB_DATABASE_SECOND} + +# Runs unconditionally, including when the fixture failed to build or an +# assertion failed. Leaving stray databases on the instance is not acceptable, +# so this takes them SINGLE_USER first rather than letting a stray session block +# the drop and leak the database. +MDB_CLEANUP_SQL = """ +SET NOCOUNT ON; +GO + +IF DB_ID('%(first)s') IS NOT NULL +BEGIN + ALTER DATABASE %(first)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE %(first)s; +END; +GO + +IF DB_ID('%(second)s') IS NOT NULL +BEGIN + ALTER DATABASE %(second)s SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE %(second)s; +END; +GO +""" % {"first": MDB_DATABASE_FIRST, "second": MDB_DATABASE_SECOND} + + +# Group G's fixture. Runs AFTER MDB_SETUP_SQL has created CrapA and CrapB, and +# just adds two tables to each. Kept separate from MDB_SETUP_SQL so Group F's +# narrative stays about mdb_test alone; the shared teardown (MDB_CLEANUP_SQL drops +# both databases) already carries these tables away. +# +# The bug material goes in CrapA because it is processed first, so its rows are +# the stale ones still sitting in #index_analysis when CrapB's pass truncates +# #index_details. CrapB gets plain copies so the loop takes a real second +# iteration for whichever table each scoped run targets. +# +# icg_merge: a Key Superset (col_a, col_b) INCLUDE (col_c) over a subset +# (col_a) INCLUDE (col_d). Rule 4/6 merges the subset's col_d into the superset, +# whose correct merged script is INCLUDE (col_c, col_d). On CrapB's pass the +# include-merge recomputes CrapA's superset from an emptied #index_details, gets +# NULL, and overwrites it -- the merge-script insert then emits a stripped row +# with no INCLUDE that can win the final ROW_NUMBER tie. +# +# icg_uc: a UNIQUE constraint uq_icg (col_a, id) with a same-key plain index +# ix_uc_plain. Rule 7.5 promotes the index to replace the constraint and drops +# the constraint. On CrapB's pass the DISABLE-script insert's +# NOT EXISTS (#index_details ... is_unique_constraint = 1) guard passes vacuously +# for stale uq_icg and emits ALTER INDEX [uq_icg] ... DISABLE on top of the +# correct DROP CONSTRAINT. +# +# No modulo (%) anywhere in the DDL: this string is %-formatted for the database +# names, so a literal % would be read as a format specifier. +MDB_G_SETUP_SQL = """ +SET NOCOUNT ON; +GO + +USE %(first)s; +GO + +/* Bug 1: superset/subset include-merge pair. */ +CREATE TABLE + dbo.icg_merge +( + id integer NOT NULL, + col_a integer NOT NULL, + col_b integer NOT NULL, + col_c integer NOT NULL, + col_d integer NOT NULL, + CONSTRAINT pk_icg_merge PRIMARY KEY CLUSTERED (id) +); + +INSERT INTO + dbo.icg_merge +( + id, + col_a, + col_b, + col_c, + col_d +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_b = 1, + col_c = 2, + col_d = 3 +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +CREATE INDEX ix_merge_super ON dbo.icg_merge (col_a, col_b) INCLUDE (col_c); +CREATE INDEX ix_merge_sub ON dbo.icg_merge (col_a) INCLUDE (col_d); +GO + +/* Bug 2: unique constraint + same-key plain index. */ +CREATE TABLE + dbo.icg_uc +( + id integer NOT NULL, + col_a integer NOT NULL, + col_c integer NOT NULL, + CONSTRAINT pk_icg_uc PRIMARY KEY CLUSTERED (id) +); + +INSERT INTO + dbo.icg_uc +( + id, + col_a, + col_c +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_c = 1 +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +ALTER TABLE dbo.icg_uc ADD CONSTRAINT uq_icg UNIQUE (col_a, id); +CREATE INDEX ix_uc_plain ON dbo.icg_uc (col_a, id) INCLUDE (col_c); +GO + +USE %(second)s; +GO + +/* Plain copies so the second iteration does real work for the scoped table. */ +CREATE TABLE + dbo.icg_merge +( + id integer NOT NULL, + col_a integer NOT NULL, + col_b integer NOT NULL +); + +INSERT INTO + dbo.icg_merge +( + id, + col_a, + col_b +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = 1, + col_b = 2 +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +CREATE INDEX ix_merge_b ON dbo.icg_merge (col_a); +GO + +CREATE TABLE + dbo.icg_uc +( + id integer NOT NULL, + col_a integer NOT NULL, + col_c integer NOT NULL +); + +INSERT INTO + dbo.icg_uc +( + id, + col_a, + col_c +) +SELECT TOP (20000) + id = ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), + col_a = 1, + col_c = 2 +FROM sys.all_columns AS ac1 +CROSS JOIN sys.all_columns AS ac2 +OPTION(MAXDOP 1); + +CREATE INDEX ix_uc_b ON dbo.icg_uc (col_a); +GO +""" % {"first": MDB_DATABASE_FIRST, "second": MDB_DATABASE_SECOND} + + +def run_sqlcmd(server, password, input_file=None, query=None, + database=TEST_DATABASE, timeout=600): + """Run SQL from a file or a query string and capture output.""" + cmd = [ + *_sqlcmd_prefix(), + "-S", server, "-U", "sa", "-P", password, + "-d", database, + "-W", # trim trailing spaces + "-s", "\t", # tab delimiter + ] + if input_file is not None: + cmd += ["-i", input_file] + else: + cmd += ["-Q", query] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return result.stdout, result.stderr + + +def run_sql_script(server, password, sql, database=TEST_DATABASE, timeout=600): + """Run a multi-batch script (one containing GO) through a temp file.""" + path = None + try: + handle, path = tempfile.mkstemp(suffix=".sql", prefix="ic_rule_cov_") + with os.fdopen(handle, "w") as f: + f.write(sql) + return run_sqlcmd(server, password, input_file=path, + database=database, timeout=timeout) + finally: + if path and os.path.exists(path): + os.remove(path) + + +def sql_errors(stdout, stderr): + """ + Every SQL error worth failing a fixture over, from both streams. + + go-sqlcmd reports SQL errors on stdout, and severities 17-19 matter as much + as 16 -- an earlier version of this looked for the literal "Level 16" on + stderr only, which is how adversarial_test.sql managed to fail on every run + for months while the suite stayed green. + """ + # Level 16-19 are the usual "this statement failed" errors. Msg 4060 and + # Msg 911 ("Cannot open database") are only Level 11, but they mean the + # script never ran at all -- a missing scratch database, exactly the failure + # that once left this suite silently green on a fresh instance. Catch those + # explicitly so they fail loudly too. + pattern = r"Msg (?:\d+, Level 1[6-9]|4060|911)[^\n]*" + return (re.findall(pattern, stdout or "") + + re.findall(pattern, stderr or "")) + + +def parse_output(stdout): + """ + Parse sp_IndexCleanup tab-delimited output into rows. + + Bounded to the script result set: the procedure emits several result sets and + the later ones share nothing but a tab delimiter, so parsing stops at the + blank line that ends this one. That bound matters here because most of the + assertions below are assertions of absence. + + Column order, verified empirically against the running procedure: + script_type, additional_info, database_name, schema_name, table_name, + index_name, consolidation_rule, target_index_name, superseded_info, + index_size_gb, index_rows, index_reads, index_writes, + original_index_definition, script + """ + rows = [] + lines = stdout.split("\n") + headers = None + started = False + + for line in lines: + if headers is None: + if "script_type" in line and "index_name" in line: + headers = [h.strip() for h in line.split("\t")] + continue + + if line.startswith("---"): + continue + + if not line.strip(): + if started: + break + continue + + cols = [c.strip() for c in line.split("\t")] + if len(cols) >= len(headers): + rows.append(dict(zip(headers, cols))) + started = True + + return rows + + +def find_rows(rows, **filters): + """Find rows matching all filter criteria.""" + matches = [] + for row in rows: + match = True + for key, value in filters.items(): + if key.endswith("__like"): + col = key[:-6] + if col not in row or value.lower() not in row[col].lower(): + match = False + break + elif key.endswith("__in"): + col = key[:-4] + if col not in row or row[col] not in value: + match = False + break + else: + if key not in row or row[key] != value: + match = False + break + if match: + matches.append(row) + return matches + + +def dedupe_rows(rows, index_name): + """Every dedupe-action row for an index. Compression rows are not included.""" + return [ + r for r in rows + if r.get("index_name") == index_name + and r.get("script_type") in DEDUPE_SCRIPT_TYPES + ] + + +def get_uptime_days(server, password): + """Server uptime in days, as the procedure itself computes it.""" + stdout, _ = run_sqlcmd( + server, password, database="master", + query="SET NOCOUNT ON; SELECT uptime = DATEDIFF(DAY, osi.sqlserver_start_time," + " SYSDATETIME()) FROM sys.dm_os_sys_info AS osi;", + ) + for line in stdout.split("\n"): + line = line.strip() + if line.isdigit(): + return int(line) + raise RuntimeError("Could not read server uptime:\n%s" % stdout) + + +def get_database_id(server, password, database_name): + """ + A database's database_id, or None if it does not exist. + + Group F needs this because the procedure's database cursor is + ORDER BY database_id: the id, not the name, is what decides which database + the loop processes first. + """ + stdout, _ = run_sqlcmd( + server, password, database="master", + query="SET NOCOUNT ON; SELECT database_id = DB_ID('%s');" % database_name, + ) + for line in stdout.split("\n"): + line = line.strip() + if line.isdigit(): + return int(line) + return None + + +def run_proc(server, password, table_name, extra="", debug=False): + """Run sp_IndexCleanup scoped to one table and return (rows, stdout).""" + query = ( + "EXECUTE master.dbo.sp_IndexCleanup " + "@database_name = '%s', @schema_name = 'dbo', @table_name = '%s'%s%s;" + % (TEST_DATABASE, table_name, extra, ", @debug = 1" if debug else "") + ) + stdout, _ = run_sqlcmd(server, password, database="master", query=query) + return parse_output(stdout), stdout + + +def run_proc_all_databases(server, password, table_name): + """ + Run sp_IndexCleanup across every database on the instance, scoped to one + table, and return (rows, stdout). + + @get_all_databases = 1 is the entire point: it is the only way to make the + procedure loop, and the loop is what Group F is testing. @table_name keeps it + cheap -- every database without that table is dismissed immediately. + """ + query = ( + "EXECUTE master.dbo.sp_IndexCleanup " + "@get_all_databases = 1, @schema_name = 'dbo', @table_name = '%s';" + % table_name + ) + stdout, _ = run_sqlcmd(server, password, database="master", query=query) + return parse_output(stdout), stdout + + +def run_tests(server, password, uptime_days): + """Run all assertions and return results.""" + results = [] + + def assert_test(group, name, condition, detail=""): + results.append({ + "group": group, + "name": name, + "passed": condition, + "detail": detail, + }) + + # ---- Group A: Rule 1, unused index detection ---- + + rows, _ = run_proc(server, password, "ic_unused_test") + unused_rows = [ + r for r in rows + if "unused index" in (r.get("consolidation_rule") or "").lower() + ] + + if uptime_days > 7: + # Rule 1 is reachable: @dedupe_only defaults to 0 and stays there. + matches = find_rows(rows, index_name="ix_never_read", + script_type="DISABLE SCRIPT", + consolidation_rule__like="Unused Index") + assert_test("A-Rule1", "ix_never_read disabled as Unused Index", + len(matches) == 1, + "found %d (uptime %d days, Rule 1 reachable)" + % (len(matches), uptime_days)) + else: + # Rule 1 is unreachable by design. Assert the guard instead. + # + # Positive controls first: without them "no Unused Index rows" would be + # true for any number of uninteresting reasons. + present = find_rows(rows, index_name="ix_never_read") + assert_test("A-Guard", "positive control: ix_never_read was analyzed", + len(present) >= 1, + "found %d rows for the index (%s)" + % (len(present), [p["script_type"] for p in present])) + + reads = present[0].get("index_reads") if present else None + assert_test("A-Guard", "positive control: ix_never_read has zero reads", + reads == "0", "index_reads=%s" % reads) + + # The procedure says so itself when asked with @debug = 1. + _, debug_stdout = run_proc(server, password, "ic_unused_test", debug=True) + assert_test("A-Guard", "@dedupe_only auto-enabled by the uptime guard", + GUARD_MESSAGE in debug_stdout, + "procedure reported the auto-enable" + if GUARD_MESSAGE in debug_stdout + else "guard message never appeared with @debug = 1") + + # Therefore Rule 1 never ran, and nothing is recommended as unused. + assert_test("A-Guard", "no Unused Index rows while the guard is active", + len(unused_rows) == 0, + "found %d unused rows (expected 0)" % len(unused_rows)) + + # ---- Group B: @min_reads / @min_writes index-level screen ---- + + # B1: no floor. ix_hot and ix_warm are key duplicates and should pair up. + rows, _ = run_proc(server, password, "ic_min_reads_test", extra=", @min_reads = 0") + + hot = dedupe_rows(rows, "ix_hot") + warm = dedupe_rows(rows, "ix_warm") + assert_test("B-MinReads", "@min_reads = 0: ix_hot/ix_warm get a dedupe recommendation", + len(hot) >= 1 and len(warm) >= 1, + "ix_hot=%s ix_warm=%s" + % ([h["script_type"] for h in hot], [w["script_type"] for w in warm])) + + # B2: floor above ix_warm's 5 reads. ix_warm is below it, so the pair cannot + # be deduped -- both sides must clear the floor on their own. + rows, _ = run_proc(server, password, "ic_min_reads_test", extra=", @min_reads = 100") + + warm = dedupe_rows(rows, "ix_warm") + assert_test("B-MinReads", "@min_reads = 100: ix_warm not deduped (5 reads, below floor)", + len(warm) == 0, + "found %d (%s)" % (len(warm), [w["script_type"] for w in warm])) + + # And the half that actually matters: the screen gates dedupe ONLY. Losing a + # compression recommendation to a reads floor would be a bug -- compression + # eligibility has nothing to do with how often an index is read. + matches = find_rows(rows, index_name="ix_warm", script_type="COMPRESSION SCRIPT") + assert_test("B-MinReads", "@min_reads = 100: ix_warm still gets COMPRESSION SCRIPT", + len(matches) == 1, "found %d" % len(matches)) + + matches = find_rows(rows, index_name="pk_ic_min_reads_test", + script_type="COMPRESSION SCRIPT") + assert_test("B-MinReads", "@min_reads = 100: PK still gets COMPRESSION SCRIPT", + len(matches) == 1, "found %d" % len(matches)) + + # B3: write floor only. ix_w1/ix_w2 have 60 writes and zero reads, so they + # clear on the write side alone. + rows, _ = run_proc(server, password, "ic_min_writes_test", extra=", @min_writes = 50") + + w1 = dedupe_rows(rows, "ix_w1") + w2 = dedupe_rows(rows, "ix_w2") + assert_test("B-MinWrites", "@min_writes = 50, no reads floor: write-only pair still dedupes", + len(w1) >= 1 and len(w2) >= 1, + "ix_w1=%s ix_w2=%s" + % ([w["script_type"] for w in w1], [w["script_type"] for w in w2])) + + # B4: the same pair against a reads floor it cannot meet. Together with B3 + # this is what makes the floors an OR rather than an AND: identical indexes, + # identical usage, deduped under the write floor and not under the read floor. + rows, _ = run_proc(server, password, "ic_min_writes_test", extra=", @min_reads = 50") + + w1 = dedupe_rows(rows, "ix_w1") + w2 = dedupe_rows(rows, "ix_w2") + assert_test("B-MinWrites", "@min_reads = 50 alone: same pair not deduped (0 reads)", + len(w1) == 0 and len(w2) == 0, + "ix_w1=%s ix_w2=%s" + % ([w["script_type"] for w in w1], [w["script_type"] for w in w2])) + + # Compression survives this screen too. + matches = find_rows(rows, index_name="ix_w1", script_type="COMPRESSION SCRIPT") + assert_test("B-MinWrites", "@min_reads = 50 alone: ix_w1 still gets COMPRESSION SCRIPT", + len(matches) == 1, "found %d" % len(matches)) + + # ---- Group C: nonclustered PRIMARY KEYs are constraint-backed ---- + # + # A PK cannot be rebuilt via DROP_EXISTING with different includes (Msg 1907) + # and must never be disabled (it succeeds, and takes every inbound foreign key + # down with it). is_primary_key is not is_unique_constraint -- the latter is 0 + # for a primary key -- so a PK needs its own protection everywhere a unique + # constraint gets it. + + # C1: PK with a key duplicate. Nothing may target the PK for a rebuild. + rows, _ = run_proc(server, password, "ic_pk_key_test") + + pk = "pk_ic_pk_key_test" + + merge_rows = find_rows(rows, index_name=pk, script_type="MERGE SCRIPT") + assert_test("C-PrimaryKey", "PK with a key duplicate gets no MERGE SCRIPT", + len(merge_rows) == 0, + "found %d (any is CREATE UNIQUE INDEX ... DROP_EXISTING vs a PK -> Msg 1907)" + % len(merge_rows)) + + # Nothing may name the PK in ANY dedupe action, merge or disable. + pk_dedupe = dedupe_rows(rows, pk) + assert_test("C-PrimaryKey", "PK with a key duplicate gets no dedupe action at all", + len(pk_dedupe) == 0, + "found %s" % [r["script_type"] for r in pk_dedupe]) + + # No OTHER index may claim the PK as its merge/disable target either: that is + # the half that silently drops a covering column when the rebuild fails. + targeting = [ + r for r in rows + if r.get("target_index_name") == pk + and r.get("script_type") in DEDUPE_SCRIPT_TYPES + ] + assert_test("C-PrimaryKey", "no script targets the PK as a consolidation winner", + len(targeting) == 0, + "found %s" % [(r["index_name"], r["script_type"]) for r in targeting]) + + # Positive control: proving the PK is protected is only meaningful if the + # procedure actually looked at it. Compression is the proof it is still in + # play and was not made invisible to fix the bug above. + matches = find_rows(rows, index_name=pk, script_type="COMPRESSION SCRIPT") + assert_test("C-PrimaryKey", "positive control: PK still gets COMPRESSION SCRIPT", + len(matches) == 1, "found %d" % len(matches)) + + # C2: PK with an exact duplicate, priorities tied, PK sorting later. This is + # the case that used to emit ALTER INDEX [pk...] DISABLE -- a script that + # executes cleanly and silently turns off referential integrity. + rows, _ = run_proc(server, password, "ic_pk_exact_test") + + pk = "zz_pk_ic_pk_exact_test" + + disable_rows = find_rows(rows, index_name=pk, script_type="DISABLE SCRIPT") + assert_test("C-PrimaryKey", "PK with an exact duplicate gets no DISABLE SCRIPT", + len(disable_rows) == 0, + "found %d (a PK DISABLE succeeds and disables inbound FKs)" + % len(disable_rows)) + + pk_dedupe = dedupe_rows(rows, pk) + assert_test("C-PrimaryKey", "PK with an exact duplicate gets no dedupe action at all", + len(pk_dedupe) == 0, + "found %s" % [r["script_type"] for r in pk_dedupe]) + + matches = find_rows(rows, index_name=pk, script_type="COMPRESSION SCRIPT") + assert_test("C-PrimaryKey", "positive control: tied PK still gets COMPRESSION SCRIPT", + len(matches) == 1, "found %d" % len(matches)) + + # ---- Group D: sort direction is part of an index's identity ---- + # + # Rule 5 has no is_descending_key comparison of its own. The ' DESC' embedded + # in key_columns is the entire reason (col_a, col_b) and (col_a, col_b DESC) + # do not hash alike, and a false Key Duplicate here disables a real index + # with a script that runs without complaint. + rows, _ = run_proc(server, password, "ic_sort_dir_test") + + for name in ("ix_sort_asc", "ix_sort_desc"): + actions = dedupe_rows(rows, name) + assert_test("D-SortDirection", + "%s: ASC/DESC pair is not a duplicate (no dedupe action)" % name, + len(actions) == 0, + "found %s" % [(r["script_type"], r["consolidation_rule"]) + for r in actions]) + + # Positive control: both were analyzed, so the absence above is the direction + # check holding rather than the indexes never being considered. + for name in ("ix_sort_asc", "ix_sort_desc"): + matches = find_rows(rows, index_name=name) + assert_test("D-SortDirection", "positive control: %s was analyzed" % name, + len(matches) >= 1, + "found %d rows (%s)" + % (len(matches), [m["script_type"] for m in matches])) + + # ---- Group E: a merged index may not INCLUDE its own key column ---- + # + # Rule 6 merges a subset's includes into the superset that absorbs it. When + # the subset's include is already a KEY of the superset, keeping it emits + # CREATE INDEX (col_a, col_b) INCLUDE (col_b), which SQL Server rejects with + # Msg 1909 -- while the paired DISABLE of the subset still runs. + rows, _ = run_proc(server, password, "ic_key_include_test") + + merge = find_rows(rows, index_name="ix_ki_superset", script_type="MERGE SCRIPT") + assert_test("E-KeyInclude", "positive control: the superset gets a MERGE SCRIPT", + len(merge) == 1, "found %d" % len(merge)) + + if merge: + script = merge[0].get("script") or "" + include_part = script.split("INCLUDE", 1)[1] if "INCLUDE" in script else "" + assert_test("E-KeyInclude", + "merged superset does not INCLUDE its own key column col_b", + "[col_b]" not in include_part, + "INCLUDE clause was '%s' in: %s" + % (include_part.strip()[:60], script[:110])) + + # And the subset really was disabled in its favor, so the merge above is the + # live code path rather than a rule that never fired. + subset = find_rows(rows, index_name="ix_ki_subset", script_type="DISABLE SCRIPT") + assert_test("E-KeyInclude", "positive control: the key subset is disabled", + len(subset) == 1, "found %d" % len(subset)) + + # ---- Group F: #index_details is per-database, #index_analysis is not ---- + # + # Under @get_all_databases, #index_details is TRUNCATED for each database in + # the loop while #index_analysis ACCUMULATES across all of them for the final + # output, so every rule re-runs over rows belonging to databases that were + # already processed. + # + # Rules 2/3/5/7 survive it: each requires EXISTS (#index_details ... + # is_eligible_for_dedupe = 1), which finds nothing for a stale row and fails + # CLOSED. Rule 7.6 had only NOT EXISTS (... is_unique_constraint = 1), which + # passes VACUOUSLY once the table is empty -- so on CrapB's pass it paired + # CrapA's leftover MAKE UNIQUE winner with CrapA's primary key and marked the + # PK DISABLE. The script-generation backstops read #index_details too, so + # they passed vacuously in turn and the script shipped: + # + # ALTER INDEX [pk_mdb] ON [CrapA].[dbo].[mdb_test] DISABLE; + # + # which runs cleanly and silently disables every inbound foreign key. + rows, _ = run_proc_all_databases(server, password, MDB_TABLE) + + # Positive controls FIRST, because every assertion in this group is an + # assertion of absence and each control below is a precondition the bug needs + # in order to fire at all. If any of them stops holding, "no DISABLE named the + # PK" becomes true for a reason that has nothing to do with the guard, and + # this group would pass while testing nothing. + + # F-PC1: CrapA has to be processed BEFORE CrapB or there is no stale-row pass. + # The cursor is ORDER BY database_id, not by name, so this is creation order. + # Asserted rather than assumed: the names sorting the "right" way is a + # coincidence, not the mechanism, and nothing would announce it if it flipped. + id_first = get_database_id(server, password, MDB_DATABASE_FIRST) + id_second = get_database_id(server, password, MDB_DATABASE_SECOND) + assert_test("F-MultiDatabase", + "positive control: %s is processed before %s" + % (MDB_DATABASE_FIRST, MDB_DATABASE_SECOND), + id_first is not None + and id_second is not None + and id_first < id_second, + "database_id %s=%s %s=%s (cursor is ORDER BY database_id)" + % (MDB_DATABASE_FIRST, id_first, MDB_DATABASE_SECOND, id_second)) + + # F-PC2: CrapA's table was really analyzed and its PK is visible to the + # procedure. This is the control that stops "no DISABLE named the PK" from + # passing because the fixture never built or the database was never reached. + # It is also the half that keeps the fix honest: protecting the PK must not + # be implemented by making primary keys invisible. + pk_compression = find_rows(rows, database_name=MDB_DATABASE_FIRST, + index_name=MDB_PK, script_type="COMPRESSION SCRIPT") + assert_test("F-MultiDatabase", + "positive control: %s.%s gets a COMPRESSION SCRIPT" + % (MDB_DATABASE_FIRST, MDB_PK), + len(pk_compression) == 1, "found %d" % len(pk_compression)) + + # F-PC3: Rule 7.5 fired and left a MAKE UNIQUE winner behind. This is the + # precondition that matters most. The bug pairs a stale PK against exactly + # this winner, so if the unique constraint stopped being replaced there would + # be nothing to pair with and the absence assertions below would be vacuous. + winner = find_rows(rows, database_name=MDB_DATABASE_FIRST, index_name="ix_mdb", + script_type="MERGE SCRIPT", + consolidation_rule__like="Unique Constraint Replacement") + assert_test("F-MultiDatabase", + "positive control: ix_mdb is the MAKE UNIQUE winner (Rule 7.5 fired)", + len(winner) == 1, + "found %d -- without this winner the bug has nothing to pair a " + "stale PK against" % len(winner)) + + # F-PC4: CrapB was actually reached, so the loop genuinely took a second + # iteration. One database processed means #index_details is never truncated + # out from under anything and the whole scenario evaporates. + second_db = find_rows(rows, database_name=MDB_DATABASE_SECOND) + assert_test("F-MultiDatabase", + "positive control: %s was reached (a second loop iteration happened)" + % MDB_DATABASE_SECOND, + len(second_db) >= 1, + "found %d rows for %s (%s)" + % (len(second_db), MDB_DATABASE_SECOND, + [r["index_name"] for r in second_db])) + + # The assertions. Not scoped to a database: a primary key must not be + # disabled in ANY of them, and the bug's victim is whichever database the + # loop happened to process first. + disable_rows = find_rows(rows, index_name=MDB_PK, script_type="DISABLE SCRIPT") + assert_test("F-MultiDatabase", + "no DISABLE SCRIPT names the primary key, in any database", + len(disable_rows) == 0, + "found %d (a PK DISABLE succeeds and disables inbound FKs) %s" + % (len(disable_rows), + [(r["database_name"], r["consolidation_rule"]) for r in disable_rows])) + + # Broader than the DISABLE above: no dedupe action of any kind may name the + # PK. A MERGE against a constraint-backed index is Msg 1907, and a DISABLE + # CONSTRAINT would drop the key outright. + pk_dedupe = dedupe_rows(rows, MDB_PK) + assert_test("F-MultiDatabase", + "no dedupe action of any kind targets the primary key", + len(pk_dedupe) == 0, + "found %s" + % [(r["database_name"], r["script_type"], r["consolidation_rule"]) + for r in pk_dedupe]) + + # And nothing may claim the PK as its consolidation winner either -- the + # other direction of the same pairing. + targeting = [ + r for r in rows + if r.get("target_index_name") == MDB_PK + and r.get("script_type") in DEDUPE_SCRIPT_TYPES + ] + assert_test("F-MultiDatabase", + "no script targets the primary key as a consolidation winner", + len(targeting) == 0, + "found %s" % [(r["database_name"], r["index_name"], r["script_type"]) + for r in targeting]) + + # ---- Group G: include-merge scripts and unique-constraint DISABLE survive the loop ---- + # + # Same defect class as Group F -- per-database temp tables are truncated each + # iteration while #index_analysis accumulates, so every rule and every + # script-generation statement re-runs over rows belonging to databases + # already processed. Group F caught the primary-key DISABLE. These are two + # other ways it surfaced before the per-database scoping fix, both in CrapA + # (processed first, so its rows are the stale ones during CrapB's pass): + # + # Bug 1: the include-merge machinery recomputes CrapA's superset winner from + # an emptied #index_details on CrapB's pass, gets NULL, and overwrites its + # merged includes. The merge-script insert then emits a SECOND, stripped row + # (no INCLUDE, no DATA_COMPRESSION, no ON [filegroup]) and the final + # ROW_NUMBER ties it against the good one. Run, the stripped pair rebuilds + # the winner with every covering column gone while its subset is disabled -- + # and it executes without error. + # + # Bug 2: the DISABLE-script insert excludes unique constraints with + # NOT EXISTS (#index_details ... is_unique_constraint = 1), which passes + # VACUOUSLY for a stale row whose #index_details was truncated. So CrapA's + # uq_icg got ALTER INDEX ... DISABLE in addition to its correct + # DROP CONSTRAINT -- and ALTER INDEX DISABLE on a unique constraint's index + # silently disables every inbound foreign key. + # + # Every assertion below is an assertion of absence, so each is preceded by + # positive controls: the rule fired, the winner was produced, and CrapB was + # reached. Without those a green result could just mean the fixture never + # built or the rule silently stopped matching. + + # --- Bug 1: the include-merge winner must keep its INCLUDE list --- + merge_rows, _ = run_proc_all_databases(server, password, MDB_MERGE_TABLE) + + # G-PC1: the merge winner was produced at all (Rule 4/6 fired for CrapA). If + # it were not, "no merge script is missing its INCLUDE" would pass vacuously. + merge_winner = find_rows(merge_rows, database_name=MDB_DATABASE_FIRST, + index_name=MDB_MERGE_WINNER, script_type="MERGE SCRIPT") + assert_test("G-MultiDatabase", + "positive control: %s.%s gets a MERGE SCRIPT (include-merge fired)" + % (MDB_DATABASE_FIRST, MDB_MERGE_WINNER), + len(merge_winner) == 1, "found %d" % len(merge_winner)) + + # G-PC2: its subset really was disabled -- the other half of the pair whose + # includes the merge is supposed to absorb. No subset, no merge to strip. + merge_subset = find_rows(merge_rows, database_name=MDB_DATABASE_FIRST, + index_name=MDB_MERGE_SUBSET, script_type="DISABLE SCRIPT") + assert_test("G-MultiDatabase", + "positive control: %s.%s (the subset) is disabled" + % (MDB_DATABASE_FIRST, MDB_MERGE_SUBSET), + len(merge_subset) == 1, "found %d" % len(merge_subset)) + + # G-PC3: CrapB was reached for this table, so the loop took a real second + # iteration and #index_details was truncated out from under CrapA's rows. + merge_second = find_rows(merge_rows, database_name=MDB_DATABASE_SECOND) + assert_test("G-MultiDatabase", + "positive control: %s was reached for %s (a second iteration happened)" + % (MDB_DATABASE_SECOND, MDB_MERGE_TABLE), + len(merge_second) >= 1, + "found %d rows for %s" % (len(merge_second), MDB_DATABASE_SECOND)) + + # The assertion. The surviving merge script must still carry an INCLUDE. The + # stripped row the bug produced has the INCLUDE clause gone entirely, so any + # merge row for the winner whose script has no INCLUDE is the defect. + merge_stripped = [ + r for r in merge_winner + if "include" not in r.get("script", "").lower() + ] + assert_test("G-MultiDatabase", + "no merge script for %s is missing its INCLUDE list, in any database" + % MDB_MERGE_WINNER, + len(merge_stripped) == 0, + "found %d stripped (winner rebuilt without its covering columns): %s" + % (len(merge_stripped), [r.get("script") for r in merge_stripped])) + + # --- Bug 2: a unique constraint must never get ALTER INDEX ... DISABLE --- + uc_rows, _ = run_proc_all_databases(server, password, MDB_UC_TABLE) + + # G-PC4: the constraint really was replaced -- it gets its correct + # DROP CONSTRAINT. This is what makes the absence assertion meaningful: + # uq_icg WAS in play as a droppable constraint, the tool just must not ALSO + # ALTER INDEX it. + uc_drop = find_rows(uc_rows, database_name=MDB_DATABASE_FIRST, + index_name=MDB_UC_CONSTRAINT, + script_type="DISABLE CONSTRAINT SCRIPT") + assert_test("G-MultiDatabase", + "positive control: %s.%s gets a DISABLE CONSTRAINT SCRIPT" + % (MDB_DATABASE_FIRST, MDB_UC_CONSTRAINT), + len(uc_drop) == 1, "found %d" % len(uc_drop)) + + # G-PC5: the plain index that replaces it was promoted (Rule 7.5 fired). This + # is the winner the stale constraint used to get paired against. + uc_winner = find_rows(uc_rows, database_name=MDB_DATABASE_FIRST, + index_name=MDB_UC_PLAIN, script_type="MERGE SCRIPT") + assert_test("G-MultiDatabase", + "positive control: %s.%s is the MAKE UNIQUE replacement (Rule 7.5 fired)" + % (MDB_DATABASE_FIRST, MDB_UC_PLAIN), + len(uc_winner) == 1, "found %d" % len(uc_winner)) + + # G-PC6: CrapB was reached for this table too. + uc_second = find_rows(uc_rows, database_name=MDB_DATABASE_SECOND) + assert_test("G-MultiDatabase", + "positive control: %s was reached for %s (a second iteration happened)" + % (MDB_DATABASE_SECOND, MDB_UC_TABLE), + len(uc_second) >= 1, + "found %d rows for %s" % (len(uc_second), MDB_DATABASE_SECOND)) + + # The assertion. A unique CONSTRAINT must never be named by an + # ALTER INDEX ... DISABLE (a DISABLE SCRIPT row), in any database. + uc_disable = find_rows(uc_rows, index_name=MDB_UC_CONSTRAINT, + script_type="DISABLE SCRIPT") + assert_test("G-MultiDatabase", + "no DISABLE SCRIPT names the unique constraint, in any database", + len(uc_disable) == 0, + "found %d (ALTER INDEX DISABLE on a UC silently disables inbound FKs) %s" + % (len(uc_disable), + [(r["database_name"], r["script"]) for r in uc_disable])) + + return results + + +def main(): + server = "SQL2022" + password = "L!nt0044" + + # Parse args + args = sys.argv[1:] + for i, arg in enumerate(args): + if arg == "--server" and i + 1 < len(args): + server = args[i + 1] + elif arg == "--password" and i + 1 < len(args): + password = args[i + 1] + + print("Running rule coverage tests against %s..." % server) + print() + + uptime_days = get_uptime_days(server, password) + print("Server uptime: %d days" % uptime_days) + + if uptime_days > 7: + print("Uptime is over 7 days, so @dedupe_only stays off and Rule 1 is") + print("reachable. Asserting that the never-read index is flagged unused.") + else: + print("Uptime is 7 days or less, so sp_IndexCleanup auto-enables") + print("@dedupe_only and Rule 1 cannot run. This is deliberate: usage data") + print("from a freshly started instance is not worth acting on. Rule 1") + print("itself is therefore not testable on this instance, so what gets") + print("asserted instead is THE GUARD -- that @dedupe_only really was") + print("auto-enabled and that no index is recommended as unused. This is a") + print("verification, not a skip.") + print() + + # Create the scratch database if it is not already there. On an established + # instance it persists between runs (nothing drops it), but a fresh instance + # -- every CI container -- has never had it. run_sql_script below connects + # with -d Crap, so without this the setup connection fails with Msg 4060 + # ("Cannot open database"), which is Level 11 and slips past the Level-16 + # error check, silently leaving the fixture unbuilt and every Crap-scoped + # assertion to fail with "found 0". CrapA/CrapB are already created this way. + print("Ensuring the %s database exists..." % TEST_DATABASE) + run_sqlcmd(server, password, database="master", + query="IF DB_ID('%s') IS NULL CREATE DATABASE %s;" + % (TEST_DATABASE, TEST_DATABASE)) + + print("Building synthetic fixture in %s..." % TEST_DATABASE) + stdout, stderr = run_sql_script(server, password, SETUP_SQL) + + # Same check the other harnesses use. + errors = sql_errors(stdout, stderr) + + if errors: + print("ERROR: SQL errors during fixture setup:") + for e in errors: + print(" " + e) + print() + print("The fixture did not build, so the assertions below would be") + print("testing something other than what they claim.") + sys.exit(1) + + print("Building multi-database fixture (%s, %s) for Groups F and G..." + % (MDB_DATABASE_FIRST, MDB_DATABASE_SECOND)) + print() + + # Everything from here down is inside the try. The databases must come off + # this instance whatever happens next -- a failed assertion, a failed fixture + # build, or an exception nobody predicted. sys.exit raises SystemExit, so the + # error path below unwinds through the same finally as a passing run does. + try: + stdout, stderr = run_sql_script(server, password, MDB_SETUP_SQL, + database="master") + errors = sql_errors(stdout, stderr) + + if errors: + print("ERROR: SQL errors during multi-database fixture setup:") + for e in errors: + print(" " + e) + print() + print("Group F's fixture did not build, so its assertions of absence") + print("would pass for the wrong reason.") + sys.exit(1) + + # Group G's tables ride on the same CrapA/CrapB the block above created. + stdout, stderr = run_sql_script(server, password, MDB_G_SETUP_SQL, + database="master") + errors = sql_errors(stdout, stderr) + + if errors: + print("ERROR: SQL errors during Group G fixture setup:") + for e in errors: + print(" " + e) + print() + print("Group G's fixture did not build, so its assertions of absence") + print("would pass for the wrong reason.") + sys.exit(1) + + results = run_tests(server, password, uptime_days) + finally: + run_sql_script(server, password, CLEANUP_SQL) + run_sql_script(server, password, MDB_CLEANUP_SQL, database="master") + + # Report + passed = sum(1 for r in results if r["passed"]) + failed = sum(1 for r in results if not r["passed"]) + + for r in results: + status = "PASS" if r["passed"] else "FAIL" + print(" [%s] %s: %s (%s)" % (status, r["group"], r["name"], r["detail"])) + + print() + print("Results: %d passed, %d failed, %d total" % (passed, failed, len(results))) + + if failed > 0: + print() + print("FAILED TESTS:") + for r in results: + if not r["passed"]: + print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"])) + sys.exit(1) + else: + print("All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/sp_IndexCleanup/tests/run_tests.py b/sp_IndexCleanup/tests/run_tests.py index 643b9a86..b921964b 100644 --- a/sp_IndexCleanup/tests/run_tests.py +++ b/sp_IndexCleanup/tests/run_tests.py @@ -7,17 +7,47 @@ python run_tests.py [--server SQL2022] [--password "L!nt0044"] """ +import os +import re +import shlex import subprocess import sys -import re + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment + so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to + 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the + go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + container's self-signed cert and disable encryption, which the modern Go + TLS stack needs to connect to the SQL Server 2017 container.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def find_sql_errors(text): + """Return any SQL errors of severity 16 or higher found in text. + + go-sqlcmd reports errors on stdout, so both streams have to be checked. + Matching the severity numerically catches Level 16 through 19 rather than + only the literal "Level 16". + """ + if not text: + return [] + + return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text) def run_sqlcmd(server, password): """Run the test SQL and capture output.""" cmd = [ - "sqlcmd", "-S", server, "-U", "sa", "-P", password, + *_sqlcmd_prefix(), + "-S", server, "-U", "sa", "-P", password, "-d", "StackOverflow2013", - "-i", "adversarial_test.sql", + "-i", os.path.join(HERE, "adversarial_test.sql"), "-W", # trim trailing spaces "-s", "\t", # tab delimiter ] @@ -93,18 +123,35 @@ def assert_test(group, name, condition, detail=""): assert_test("1-UC", "1a: NC subset of UC flagged DISABLE", len(matches) == 1, f"found {len(matches)}") - # 1a: UC merge script → CREATE UNIQUE + # 1a: A unique CONSTRAINT must NOT be picked as the wider merge target. + # + # These two assertions used to expect the opposite, and the proc used to + # oblige, emitting: + # CREATE UNIQUE INDEX [uq_uc_abc] ... INCLUDE ([col_e]) + # WITH (DROP_EXISTING = ON, ...) + # against a constraint-backed index. SQL Server rejects that outright with + # Msg 1907 ("The new index definition does not match the constraint being + # enforced by the existing index") - verified on 2016/2019/2022/2025. The + # paired DISABLE of the subset ran fine, so the net effect was losing a + # covering index while the constraint never absorbed its include. + # + # Contrast with 1d below: a plain unique INDEX does accept DROP_EXISTING + # with an added INCLUDE, so it remains a valid merge target. The dividing + # line is is_unique_constraint, not is_unique. matches = find_rows(rows, table_name="test_ic_uc", index_name="uq_uc_abc", script_type="MERGE SCRIPT") - has_unique = any("CREATE UNIQUE" in m.get("script", "") for m in matches) - assert_test("1-UC", "1a: UC merge script has CREATE UNIQUE", - has_unique, f"found {len(matches)} merge rows, unique={has_unique}") - - # 1b: NC subset of UC with includes → DISABLE + assert_test("1-UC", "1a: UC NOT used as merge target (Msg 1907)", + len(matches) == 0, f"found {len(matches)} merge rows (expected 0)") + + # 1b: A subset carrying an include the constraint cannot absorb must survive. + # Disabling it would drop col_e coverage with no working merge to replace it. + # (ix_uc_ab, which has no includes, is still correctly disabled above: the + # constraint's keys already cover it, so no script against the constraint is + # needed.) matches = find_rows(rows, table_name="test_ic_uc", index_name="ix_uc_ab_inc", script_type="DISABLE SCRIPT") - assert_test("1-UC", "1b: NC subset of UC (with includes) flagged DISABLE", - len(matches) == 1, f"found {len(matches)}") + assert_test("1-UC", "1b: NC subset of UC (with includes) NOT disabled", + len(matches) == 0, f"found {len(matches)} (expected 0)") # 1c: NC with non-prefix UC keys → NOT subset matches = find_rows(rows, table_name="test_ic_uc", index_name="ix_uc_bc", @@ -292,10 +339,52 @@ def assert_test(group, name, condition, detail=""): len(matches) == 0, f"found {len(matches)} (expected 0)") # 12b: UC + NC + subset on same table - # KNOWN ISSUE: uq_int_cd, ix_int_cd, and ix_int_c don't appear in - # output at all — needs investigation with @debug = 1 to determine - # if they're excluded at collection or rule processing stage. - # Skipping assertion for now — tracked as issue for investigation. + # 12b: UC + key-duplicate NC + key-subset NC on the same table. + # + # This was skipped for a long time with a comment saying these three indexes + # "don't appear in output at all -- needs investigation". They were absent + # because the FIXTURE was broken, not the procedure: col_c and col_d were + # random values from 200 and 100 buckets across 10,000 rows, so + # uq_int_cd UNIQUE (col_c, col_d) failed to create on every run (Msg 1505), + # and the read loop then aborted hinting the missing index (Msg 308). The + # runner could not see any of it because it checked stderr and go-sqlcmd + # reports errors on stdout. Both are fixed; on clean data the procedure gets + # this group right. + + # 12b: the constraint is replaced by its nonclustered twin + matches = find_rows(rows, table_name="test_ic_interact", index_name="uq_int_cd", + script_type="DISABLE CONSTRAINT SCRIPT") + assert_test("12-Interact", "12b: uq_int_cd constraint dropped for ix_int_cd", + len(matches) == 1, f"found {len(matches)}") + + # 12b: that twin is made unique + matches = find_rows(rows, table_name="test_ic_interact", index_name="ix_int_cd", + script_type="MERGE SCRIPT") + has_unique = any("CREATE UNIQUE" in m.get("script", "") for m in matches) + assert_test("12-Interact", "12b: ix_int_cd made unique to replace the constraint", + has_unique, f"found {len(matches)} merge rows, unique={has_unique}") + + # 12b: the key subset is disabled into it + matches = find_rows(rows, table_name="test_ic_interact", index_name="ix_int_c", + script_type="DISABLE SCRIPT") + assert_test("12-Interact", "12b: ix_int_c (key subset) disabled", + len(matches) == 1, f"found {len(matches)}") + + # 12b: THE load-bearing one. ix_int_c is being disabled, so whatever it + # covered has to survive in the index replacing it. Rule 6 merges col_b in as + # a Key Subset; Rule 7.5 then rewrites the row to MAKE UNIQUE and Rule 7.6 + # recomputes the includes. A 7.6 that gathers only Key Duplicate losers drops + # col_b here while ix_int_c's DISABLE still runs -- coverage silently gone on + # scripts that all execute cleanly, which the execute check cannot see. + matches = find_rows(rows, table_name="test_ic_interact", index_name="ix_int_cd", + script_type="MERGE SCRIPT") + script = matches[0].get("script", "") if matches else "" + include = script[script.find("INCLUDE"):script.find(")", script.find("INCLUDE")) + 1] if "INCLUDE" in script else "" + kept_subset_col = "col_b" in include + kept_own_col = "col_e" in include + assert_test("12-Interact", "12b: merge keeps BOTH its own include and the disabled subset's", + kept_subset_col and kept_own_col, + f"INCLUDE={include or '(none)'} col_b={kept_subset_col} col_e={kept_own_col}") return results @@ -317,9 +406,20 @@ def main(): stdout, stderr = run_sqlcmd(server, password) - if "Msg " in stderr and "Level 16" in stderr: - print("ERROR: SQL errors detected:") - print(stderr) + # go-sqlcmd writes SQL errors to STDOUT, not stderr. Checking stderr alone + # made this detection decorative: adversarial_test.sql was failing partway + # through on every run (Msg 1505 on test_ic_interact) and the suite stayed + # green, so group 12 was never really tested. Check both streams, and match + # any severity 16+ rather than the literal string "Level 16". + errors = find_sql_errors(stdout) + find_sql_errors(stderr) + + if errors: + print("ERROR: SQL errors detected while running the fixture:") + for e in errors: + print(" " + e) + print() + print("The fixture did not build correctly, so the assertions below") + print("would be testing something other than what they claim.") sys.exit(1) rows = parse_output(stdout) diff --git a/sp_LogHunter/sp_LogHunter.sql b/sp_LogHunter/sp_LogHunter.sql index f6f6b90d..170b4dab 100644 --- a/sp_LogHunter/sp_LogHunter.sql +++ b/sp_LogHunter/sp_LogHunter.sql @@ -73,8 +73,8 @@ SET DATEFORMAT MDY; BEGIN SELECT - @version = '3.7', - @version_date = '20260701'; + @version = '3.8', + @version_date = '20260801'; IF @help = 1 BEGIN @@ -97,7 +97,7 @@ BEGIN WHEN N'@custom_message' THEN 'if you want to search for a custom string' WHEN N'@custom_message_only' THEN 'only search for the custom string' WHEN N'@first_log_only' THEN 'only search through the first error log' - WHEN N'@language_id' THEN 'to use something other than English' + WHEN N'@language_id' THEN 'does not translate the search strings; they are English literals. Use @custom_message on non-English servers' WHEN N'@help' THEN 'how you got here' WHEN N'@debug' THEN 'dumps raw temp table contents' WHEN N'@version' THEN 'OUTPUT; for support' @@ -111,7 +111,7 @@ BEGIN WHEN N'@custom_message' THEN 'something specific you want to search for. no wildcards or substitutions.' WHEN N'@custom_message_only' THEN 'NULL, 0, 1' WHEN N'@first_log_only' THEN 'NULL, 0, 1' - WHEN N'@language_id' THEN 'SELECT DISTINCT m.language_id FROM sys.messages AS m ORDER BY m.language_id;' + WHEN N'@language_id' THEN '1033; anything else only triggers a warning, because the search strings stay English' WHEN N'@help' THEN 'NULL, 0, 1' WHEN N'@debug' THEN 'NULL, 0, 1' WHEN N'@version' THEN 'OUTPUT; for support' @@ -213,6 +213,18 @@ BEGIN RETURN; END; + /* + Say so, rather than pretending. @language_id is validated and then + never used: every search string below is an English literal matched + against the error log's raw text, so pointing this at a non-English + server finds nothing and says nothing about why. Warn instead of + quietly handing back an empty result set that reads like good news. + */ + IF @language_id <> 1033 + BEGIN + RAISERROR(N'The search strings are English literals, so @language_id = %i will not translate them. Expect few or no results on a non-English server; use @custom_message to search in your own language.', 10, 1, @language_id) WITH NOWAIT; + END; + /*Fix days back a little bit*/ IF @days_back = 0 BEGIN @@ -226,12 +238,30 @@ BEGIN @days_back *= -1; END; - IF @start_date IS NOT NULL - AND @end_date IS NOT NULL - AND @days_back IS NOT NULL + /* + A NULL @days_back with no date range is fatal, and silently so: it + makes DATEADD return NULL, which makes every generated command NULL, + and sys.sp_executesql runs a NULL batch as a no-op. The procedure + reads nothing, finds nothing, and reports a clean bill of health on a + server that may be on fire. Put it back on the documented default. + */ + IF @days_back IS NULL + AND @start_date IS NULL + AND @end_date IS NULL BEGIN SELECT - @days_back = NULL; + @days_back = -7; + END; + + /* + Clamp before DATEADD sees it. An absurd @days_back (a caller reaching + for "everything") overflows the datetime range and kills the run with + an arithmetic error instead of just searching every log we have. + */ + IF @days_back < -36500 + BEGIN + SELECT + @days_back = -36500; END; /*Fix custom message only if NULL*/ @@ -272,6 +302,28 @@ BEGIN @start_date = DATEADD(DAY, -7, @end_date); END; + /* + Retire @days_back once we are in date-range mode, and do it HERE — + after the two fixups above, not before them. + + One supplied date is enough to be in date-range mode, because a + caller who supplies only one gets the other filled in just above. + If @days_back survived at its -7 default past this point, the log + file pruner below would delete every archive older than a week — so + searching for an incident from three months ago would quietly throw + away the only file that contained it and return nothing. + */ + IF @days_back IS NOT NULL + AND + ( + @start_date IS NOT NULL + OR @end_date IS NOT NULL + ) + BEGIN + SELECT + @days_back = NULL; + END; + /*Debuggo*/ IF @debug = 1 BEGIN @@ -309,7 +361,14 @@ BEGIN ( archive integer PRIMARY KEY CLUSTERED, - log_date date, + /* + datetime, not date. sp_enumerrorlogs reports each archive's last + write with a time component; truncating it to midnight made an + archive that ended this afternoon compare as if it had ended at + 00:00, so the window bound below could rule out a file that + actually reaches into the window. + */ + log_date datetime, log_size bigint ); @@ -319,14 +378,43 @@ BEGIN id integer IDENTITY PRIMARY KEY CLUSTERED, - search_string nvarchar(4000) DEFAULT N'""', + /* + Every argument below is emitted as an N-prefixed single-quoted + literal: N'like this'. Both halves of that matter. + + NOT double quotes: under QUOTED_IDENTIFIER ON a double-quoted argument + parses as an IDENTIFIER, and SQL Server caps identifiers at 128 + characters - so a @custom_message longer than that failed with + "Msg 103 ... The identifier that starts with ... is too long", was + swallowed by the CATCH below into #errors, and handed the caller an + empty result set that reads exactly like a clean bill of health. + + NOT a bare single-quoted literal either: xp_readerrorlog is an + extended stored procedure and will not implicitly convert, so a + varchar argument fails with "Msg 22004 ... Invalid Parameter Type" - + at severity 12, quiet enough to look like simply finding nothing. + Double quotes happened to satisfy this because quoted identifiers are + inherently Unicode; N'...' satisfies it deliberately, and without the + length limit. + + The wrapping reads awkwardly because a literal quote must be doubled: + N'N''' opens the argument (emitting N') and N'''' closes it + (emitting '). + */ + search_string nvarchar(4000) DEFAULT N'N''''', days_back nvarchar(30) NULL, start_date nvarchar(30) NULL, end_date nvarchar(30) NULL, - [current_date] nvarchar(10) - DEFAULT N'"' + CONVERT(nvarchar(10), DATEADD(DAY, 1, SYSDATETIME()), 112) + N'"', + /* + nvarchar(12), not 10: the stored value is N'20260720' - eleven + characters counting the N prefix and quotes. Anything narrower + truncates it into an unterminated literal that breaks every + generated command. + */ + [current_date] nvarchar(12) + DEFAULT N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, 1, SYSDATETIME()), 112) + N'''', search_order nvarchar(10) - DEFAULT N'"DESC"', + DEFAULT N'N''DESC''', command AS CONVERT ( @@ -334,7 +422,14 @@ BEGIN N'EXECUTE master.dbo.xp_readerrorlog [@@@], 1, ' + search_string + N', ' - + N'" "' + /* + NULL, not " ". xp_readerrorlog ANDs its second search + string with the first, so passing a literal space quietly + required every matched line to contain one - a filter + nobody asked for, applied to every search including + @custom_message. + */ + + N'NULL' + N', ' + ISNULL(start_date, days_back) + N', ' @@ -351,7 +446,8 @@ BEGIN id integer PRIMARY KEY CLUSTERED IDENTITY, - command nvarchar(4000) NOT NULL + command nvarchar(4000) NOT NULL, + error_message nvarchar(4000) NULL ); /*get all the error logs*/ @@ -372,31 +468,83 @@ BEGIN DELETE e WITH(TABLOCKX) FROM #enum AS e - WHERE e.log_date < DATEADD(DAY, @days_back, SYSDATETIME()) + /* + The one-minute grace is not slop. sp_enumerrorlogs reports each + archive's last write truncated to the MINUTE, and the file's real + final entry lands somewhere in the following 59 seconds - so the + reported stamp always understates when the file actually ends. + Comparing it exactly prunes archives that really do reach into + the window. Since enum <= true_end < enum + 60s, one minute is + exactly enough. + */ + WHERE DATEADD(MINUTE, 1, e.log_date) < DATEADD(DAY, @days_back, SYSDATETIME()) AND e.archive > 0 OPTION(RECOMPILE); END; - /*filter out log files we won't use, if @start_date and @end_date are set*/ + /* + Filter out log files we won't use, if @start_date and @end_date are set. + + Only the START of the window can rule a log file out, and that is the + whole subtlety here. sp_enumerrorlogs reports each archive's LAST + write, not its first, so a file stamped "March 10" holds entries + reaching back to whenever the previous archive ended. Excluding + anything stamped AFTER @end_date would throw away precisely the file + that straddles the end of the window, i.e. the one most likely to + contain the incident being investigated. + + A file whose last write predates the window truly cannot contain it, + so that is the only safe exclusion. Keeping the newer files costs a + little extra reading and nothing else: xp_readerrorlog filters by + date again on the way out, so no out-of-window rows survive. + */ IF @start_date IS NOT NULL AND @end_date IS NOT NULL BEGIN DELETE e WITH(TABLOCKX) FROM #enum AS e - WHERE (e.log_date < CONVERT(date, @start_date) - OR e.log_date > CONVERT(date, @end_date)) - AND e.archive > 0 + /* + A file's last write is where it ENDS; where it STARTS is the last + write of the archive before it (the one rotated out to make room). + Archives count upward into the past, so that is the next-higher + number. The oldest file has no predecessor and reaches back + indefinitely, which a NULL here handles by never matching. + */ + OUTER APPLY + ( + SELECT TOP (1) + starts_at = e2.log_date + FROM #enum AS e2 + WHERE e2.archive > e.archive + ORDER BY + e2.archive + ) AS prior + WHERE e.archive > 0 + AND + ( + /* + Ended before we started looking. The +1 minute is the same + truncation grace as above: sp_enumerrorlogs rounds each + archive's last write DOWN to the minute. + */ + DATEADD(MINUTE, 1, e.log_date) < @start_date + /*Or did not begin until after we stopped*/ + OR prior.starts_at > @end_date + ) OPTION(RECOMPILE); END; - /*maybe you only want the first one anyway*/ + /* + Maybe you only want the first one anyway. Archive 0 IS the current + log, so "archive > 0" is the predicate that keeps exactly one file. + */ IF @first_log_only = 1 BEGIN DELETE e WITH(TABLOCKX) FROM #enum AS e - WHERE e.archive > 1 + WHERE e.archive > 0 OPTION(RECOMPILE); END; @@ -419,10 +567,10 @@ BEGIN FROM ( VALUES - (N'"Microsoft SQL Server"'), - (N'"detected"'), - (N'"SQL Server has encountered"'), - (N'"Warning: Enterprise Server/CAL license used for this instance"') + (N'N''Microsoft SQL Server'''), + (N'N''detected'''), + (N'N''SQL Server has encountered'''), + (N'N''Warning: Enterprise Server/CAL license used for this instance''') ) AS x (search_string) CROSS JOIN ( @@ -431,24 +579,23 @@ BEGIN Canary floor is normally "at least 90 days back" so these server-identity strings are found regardless of how recent the caller is interested in. When the caller supplied - @start_date/@end_date, @days_back is NULL at this point — - the previous CASE collapsed to NULL, produced a NULL - days_back literal, and xp_readerrorlog received NULL as a - date argument and errored. Fall back to @start_date in + @start_date/@end_date, @days_back is NULL at this point, and + a NULL days_back literal would reach xp_readerrorlog as a + NULL date argument and error. Fall back to @start_date in date-range mode so the canary has a concrete floor. */ days_back = - N'"' + + N'N''' + CASE WHEN @days_back IS NOT NULL THEN CONVERT(nvarchar(10), DATEADD(DAY, CASE WHEN @days_back > -90 THEN -90 ELSE @days_back END, SYSDATETIME()), 112) ELSE CONVERT(nvarchar(10), @start_date, 112) END + - N'"', + N'''', start_date = - N'"' + CONVERT(nvarchar(30), @start_date) + N'"', + N'N''' + CONVERT(nvarchar(30), @start_date, 121) + N'''', end_date = - N'"' + CONVERT(nvarchar(30), @end_date) + N'"' + N'N''' + CONVERT(nvarchar(30), @end_date, 121) + N'''' ) AS c WHERE @custom_message_only = 0 OPTION(RECOMPILE); @@ -464,9 +611,9 @@ BEGIN ) SELECT search_string = - N'"' + - v.search_string + - N'"', + N'N''' + + REPLACE(v.search_string, N'''', N'''''') + + N'''', c.days_back, c.start_date, c.end_date @@ -495,11 +642,11 @@ BEGIN ( SELECT days_back = - N'"' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'"', + N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'''', start_date = - N'"' + CONVERT(nvarchar(30), @start_date) + N'"', + N'N''' + CONVERT(nvarchar(30), @start_date, 121) + N'''', end_date = - N'"' + CONVERT(nvarchar(30), @end_date) + N'"' + N'N''' + CONVERT(nvarchar(30), @end_date, 121) + N'''' ) AS c WHERE @custom_message_only = 0 OPTION(RECOMPILE); @@ -522,16 +669,22 @@ BEGIN ( VALUES ( - /* xp_readerrorlog search strings are wrapped in double quotes - (see the #search.command computed column), so any literal " + /* xp_readerrorlog search strings are wrapped in single quotes + (see the #search.command computed column), so any literal ' inside the user-supplied @custom_message must be doubled to - avoid closing the argument early and producing an - "Incorrect syntax near '+'" error when sp_executesql parses - the generated batch. */ - N'"' + REPLACE(@custom_message, N'"', N'""') + N'"', - N'"' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'"', - N'"' + CONVERT(nvarchar(30), @start_date) + N'"', - N'"' + CONVERT(nvarchar(30), @end_date) + N'"' + avoid closing the argument early and producing a syntax + error when sys.sp_executesql parses the generated batch. A + literal " needs no handling at all now that it is not a + delimiter, and the search string is not subject to the + 128-character identifier limit that silently dropped long + messages into #errors. The nvarchar(4000) command column is + the remaining ceiling: a @custom_message within a few + characters of 4000 (one less per apostrophe) still + truncates the generated command and lands in #errors. */ + N'N''' + REPLACE(@custom_message, N'''', N'''''') + N'''', + N'N''' + CONVERT(nvarchar(10), DATEADD(DAY, @days_back, SYSDATETIME()), 112) + N'''', + N'N''' + CONVERT(nvarchar(30), @start_date, 121) + N'''', + N'N''' + CONVERT(nvarchar(30), @end_date, 121) + N'''' ) ) AS x (search_string, days_back, start_date, end_date) WHERE @custom_message LIKE N'_%' @@ -635,15 +788,17 @@ BEGIN @c; END TRY BEGIN CATCH - /*Insert any searches that throw an error here*/ + /*Insert any searches that throw an error here, and why they failed*/ INSERT #errors ( - command + command, + error_message ) VALUES ( - @c + @c, + ERROR_MESSAGE() ); END CATCH; END; diff --git a/sp_LogHunter/tests/README.md b/sp_LogHunter/tests/README.md new file mode 100644 index 00000000..92dba0e4 --- /dev/null +++ b/sp_LogHunter/tests/README.md @@ -0,0 +1,88 @@ +# sp_LogHunter Tests + +**Run before and after any change to `sp_LogHunter.sql`.** Compiling proves +almost nothing here. sp_LogHunter is a *generator*: it never queries the error +log directly, it builds an `EXECUTE master.dbo.xp_readerrorlog ...` command +string in the PERSISTED computed column `#search.command` -- one per search +string -- and runs each through `sys.sp_executesql` in a nested loop over every +log archive. A command that concatenates cleanly and reads correctly can still +fail the moment it executes. + +Worse, that failure is **silent by design**. The EXECUTE is wrapped in +`BEGIN TRY`/`BEGIN CATCH`, and any command that throws is swallowed into +`#errors` rather than raised. A broken search string therefore produces a +clean-looking but quietly incomplete result set: the procedure reports a +healthy server because it never managed to read the log. + +## Automated + +| Script | What it does | +| --- | --- | +| `run_tests.py` | Runs the parameter matrix, asserting `#errors` stays empty, plus bidirectional marker and date-range checks. 78 assertions. | + +``` +cd sp_LogHunter/tests +python run_tests.py --server SQL2022 +``` + +Takes `--server` and `--password` (default `SQL2022` / the standard local sa +password). Expect `78`. A full run takes a few seconds. + +## What it actually covers + +**`#errors` must be empty.** This is the core assertion and it runs on every +case in the matrix. The source documents four bugs already fixed this way -- a +stray literal space silently ANDed into every search, a NULL date argument in +date-range mode, an unescaped quote closing the argument early, and an absurd +`@days_back` overflowing `DATEADD`. Every one of them landed in `#errors` +instead of on the user's screen, so asserting that table is empty +regression-tests all four at once and anything else of the same shape. + +**A real positive control.** `RAISERROR('', 10, 1) WITH LOG` writes a +known string to the error log. Severity 10 is deliberate: it reaches the log +without emitting a client-side `Msg ..., Level 12+` that the error detector +would flag. The marker is chosen so none of the ~88 canned search strings match +it and none of the noise filters delete it, which makes the pair meaningful: + +- `@custom_message` = the marker -> the row **is** returned +- `@custom_message` = a string never written -> **not** returned, run still clean +- a default run with no `@custom_message` -> **not** returned + +The same shape covers date ranges (a window containing the write returns it, a +window 30 days earlier does not) and `@custom_message_only` (0 runs the canned +sweep *and* the custom search; 1 returns strictly fewer rows). Every absence +assertion is paired with a presence assertion on the same machinery, so nothing +passes vacuously by returning an empty set. + +## Bug found and fixed by this harness + +A `@custom_message` longer than **128 characters** silently returned nothing. + +Search arguments were wrapped in double quotes, and under `QUOTED_IDENTIFIER ON` +a double-quoted argument parses as an **identifier** -- which SQL Server caps at +128 characters. Anything longer failed with `Msg 103 ... The identifier that +starts with ... is too long`, was swallowed into `#errors`, and handed the +caller an empty result set that reads exactly like a clean bill of health. A +150-character search string is not exotic; pasting in an error message fragment +gets you there easily. + +The fix emits `N'...'` instead. Both halves matter, and the second one is why +the original code used double quotes in the first place: `xp_readerrorlog` is an +extended stored procedure and does **not** implicitly convert, so a bare +single-quoted (varchar) argument fails with `Msg 22004 ... Invalid Parameter +Type` at severity 12 -- quiet enough to look like simply finding nothing. Quoted +identifiers happened to satisfy the Unicode requirement; `N'...'` satisfies it +deliberately, and has no length limit. + +Because that failure is severity **12**, this harness checks for `Level 12` and +above rather than the `Level 16` its siblings use. Severity 11 is still +excluded: sp_LogHunter's own parameter guards raise at 11 by design. + +## Side effect + +This harness writes a handful of informational lines to the SQL Server error +log (the markers). That cannot be undone without cycling the log, which would be +far more disruptive than the lines themselves, so it does not try. The marker +text is fixed rather than random so repeated runs do not accumulate distinct +junk. Nothing else on the instance is touched: no databases, no configuration, +no sessions, no server settings. diff --git a/sp_LogHunter/tests/run_tests.py b/sp_LogHunter/tests/run_tests.py new file mode 100644 index 00000000..ff25d58c --- /dev/null +++ b/sp_LogHunter/tests/run_tests.py @@ -0,0 +1,617 @@ +""" +sp_LogHunter assertion test harness +=================================== +sp_LogHunter is a GENERATOR. It does not query the error log directly: it +builds an `EXECUTE master.dbo.xp_readerrorlog ...` command string in the +PERSISTED computed column #search.command, once per search string, then runs +each one through sys.sp_executesql inside a nested loop over every error log +archive. + +That makes it a textbook case of this suite's characteristic bug: a command +that concatenates cleanly, reads correctly, and still fails when executed. +Worse, the failure is SILENT by design -- the EXECUTE is wrapped in +BEGIN TRY/BEGIN CATCH, and a command that throws is swallowed into #errors +rather than raised. A broken search string produces a clean-looking, quietly +incomplete result set: the procedure reports a healthy server because it never +managed to read the log. + +The source carries comments describing four such bugs already fixed: + + * the second xp_readerrorlog search argument was a literal " ", which + xp_readerrorlog ANDs with the first -- silently requiring every matched + line to contain a space + * in date-range mode @days_back is retired to NULL, which made the canary + rows emit a NULL date argument and throw + * a literal " inside @custom_message closed the quoted argument early and + produced "Incorrect syntax near '+'" + * an absurd @days_back overflowed DATEADD and killed the run + +Every one of those lands in #errors instead of on the user's screen. So the +core assertion here is simple and blunt: **#errors must be empty**, across +every parameter combination that changes the generated command. That single +assertion regression-tests all four fixes above and anything similar. + +On top of that, the harness proves the search actually searches, using a real +positive control: + + RAISERROR('', 10, 1) WITH LOG writes a known string to the error log. + +Severity 10 is deliberate: it reaches the log while emitting no client-side +"Msg ..., Level 16" that the error detector would flag. The marker is chosen so +that NONE of the ~88 canned search strings match it and none of the noise +filters delete it, which makes the bidirectional pair meaningful: + + * @custom_message = marker -> the row IS returned + * @custom_message = never-written -> it is NOT returned, run still clean + * default run, no @custom_message -> it is NOT returned + +and likewise for date ranges: a window containing the write returns it, a +window 30 days in the past does not. Every absence assertion is paired with a +presence assertion on the same machinery, so nothing can pass vacuously by +returning an empty set. + +SIDE EFFECT: this harness writes two informational lines to the SQL Server +error log (the markers). That cannot be undone without cycling the log, which +would be far more disruptive than the two lines, so the harness does not try. +The marker text is fixed rather than random so repeated runs do not accumulate +distinct junk. Nothing else on the instance is touched: no databases, no +configuration, no sessions. + +Usage: + python run_tests.py [--server SQL2022] [--password L!nt0044] + +Exits 1 if any assertion fails. +""" + +import argparse +import os +import re +import shlex +import subprocess +import sys + + +# Written to the error log via RAISERROR ... WITH LOG, then searched for. +# Chosen so that none of sp_LogHunter's ~88 canned search strings match it +# (so a default run must NOT return it) and none of the "dumb messages" +# DELETE filters remove it (so a targeted run MUST return it). +MARKER = "DarlingDataLogHunterCanary" + +# Never written to the log by anything. The negative half of the pair. +ABSENT_MARKER = "DarlingDataLogHunterNeverWrittenAnywhere" + +# A marker containing a literal double quote. Search strings are wrapped in +# single quotes when the command is built, so a double quote must survive +# untouched; it was previously the delimiter and had to be doubled. +QUOTE_MARKER = 'DarlingDataLogHunter"Quoted"Canary' + +# A marker longer than 128 characters. Search strings used to be wrapped in +# DOUBLE quotes, which under QUOTED_IDENTIFIER ON makes them identifiers -- +# capped at 128 characters. Anything longer failed with Msg 103, was swallowed +# into #errors, and returned an empty result set that read as "nothing wrong". +# 150 characters puts this comfortably past the old ceiling. +LONG_MARKER = "DarlingDataLogHunterLongCanary" + ("X" * 120) + +# Columns expected in the #error_log result set (shape-regression guard). +LOG_COLUMNS = ["table_name", "log_date", "process_info", "text"] + +# Diagnostic lines the procedure prints only under @debug = 1. +DEBUG_MARKERS = ["@l_log:", "@t_searches:", "Declaring cursor"] + + +def find_sql_errors(text): + """Return any SQL errors of severity 12 or higher found in text. + + go-sqlcmd reports errors on stdout, so both streams have to be checked. + + The bar is 12 here, not the 16 the other harnesses use, because the + failure this procedure actually suffers from is quieter than that: + xp_readerrorlog rejects a non-Unicode argument with "Msg 22004 ... Invalid + Parameter Type" at Level 12, which reads on screen as simply finding + nothing. A Level-16 filter walks straight past it -- the same blind spot + that once let a Level-11 Msg 4060 masquerade as a procedure bug. + + Level 11 is deliberately still excluded: sp_LogHunter's own parameter + guards (non-sysadmin, invalid @language_id, @custom_message_only with no + message) raise at severity 11 by design, and those are expected output in + the validation tests rather than failures. + """ + if not text: + return [] + return re.findall(r"Msg \d+, Level 1[2-9][^\n]*", text) + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment so + one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the + go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN + at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + self-signed cert and disable encryption, since the modern Go TLS stack + rejects the SQL Server 2017 container's certificate outright.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + +def _sqlcmd(server, password, sql, headers=True): + """Run a batch and return (stdout, stderr) decoded as UTF-8. + + Capturing bytes and decoding as UTF-8 keeps any non-ASCII log text from + being mangled by the Windows console code page (text=True would do that). + Output is tab-delimited and trimmed (-W -s TAB) and the line width is maxed + (-w 65535) so wide log rows are not wrapped mid-row. + """ + cmd = _sqlcmd_prefix() + [ + "-S", server, "-U", "sa", "-P", password, + "-d", "master", + "-W", # trim trailing spaces + "-w", "65535", # do not wrap wide rows + "-s", "\t", # tab delimiter + ] + if not headers: + cmd += ["-h", "-1"] + cmd += ["-Q", sql] + r = subprocess.run(cmd, capture_output=True, timeout=300) + out = (r.stdout or b"").decode("utf-8", errors="replace") + err = (r.stderr or b"").decode("utf-8", errors="replace") + return out, err + + +def _esc(s): + """Escape a T-SQL single-quoted literal.""" + return s.replace("'", "''") + + +def run_loghunter(server, password, args="", preamble=""): + """Run sp_LogHunter and return (stdout, stderr). + + `preamble` allows a DECLARE block so date arguments can be computed + SERVER-side (SYSDATETIME), which avoids any clock or timezone skew between + this machine and the instance under test. + """ + call = "EXECUTE dbo.sp_LogHunter %s;" % args if args else "EXECUTE dbo.sp_LogHunter;" + return _sqlcmd(server, password, "SET NOCOUNT ON; " + preamble + call) + + +def write_marker(server, password, marker): + """Write a marker line into the SQL Server error log. + + Severity 10 with WITH LOG reaches the error log without raising a + client-side Msg/Level 16 that find_sql_errors would flag as a failure. + Requires sysadmin, which the harness already needs to read the log at all. + """ + out, err = _sqlcmd(server, password, + "SET NOCOUNT ON; RAISERROR('%s', 10, 1) WITH LOG;" % _esc(marker)) + return out + "\n" + err + + +def log_rows(stdout): + """Return the #error_log data rows as lists of tab-split fields. + + A real row's first field is exactly '#error_log'. Error log text can + contain embedded newlines, which produce continuation lines carrying only + the tail of `text`; those do not start with the table marker and are + skipped. The '#errors' result set is likewise excluded (different marker). + """ + rows = [] + for line in stdout.splitlines(): + fields = line.split("\t") + if fields and fields[0].strip() == "#error_log": + rows.append([f.strip() for f in fields]) + return rows + + +def failed_commands(stdout): + """Return the generated commands that THREW, from the #errors result set. + + sp_LogHunter swallows a failing xp_readerrorlog command into #errors rather + than raising it, so this -- not the exit code, and not find_sql_errors -- is + how a broken generated command is detected. Anything here is a real defect. + """ + cmds = [] + for line in stdout.splitlines(): + fields = line.split("\t") + if fields and fields[0].strip() == "#errors": + cmds.append(line.strip()) + return cmds + + +def log_header_ok(stdout): + """True if the #error_log result set header carries every expected column.""" + for line in stdout.splitlines(): + if "log_date" in line and "process_info" in line and "text" in line: + return all(col in line for col in LOG_COLUMNS) + return False + + +def marker_found(stdout, marker): + """True if any #error_log row's text contains the marker.""" + return any(marker in row[-1] for row in log_rows(stdout)) + + +class Results: + def __init__(self): + self.items = [] + + def check(self, group, name, condition, detail=""): + self.items.append({ + "group": group, + "name": name, + "passed": bool(condition), + "detail": detail, + }) + + @property + def passed(self): + return sum(1 for r in self.items if r["passed"]) + + @property + def failed(self): + return sum(1 for r in self.items if not r["passed"]) + + +def clean_run(R, group, name, out, err, expect_rows=True): + """Assert one run was clean: no severe SQL error AND no generated command + landed in #errors. Returns the parsed rows so callers can assert further. + + This is the workhorse. #errors being empty is the assertion that actually + matters, because that is where a malformed generated command goes to die + quietly. + """ + combined = out + "\n" + err + errors = find_sql_errors(combined) + R.check(group, "%s: no severe SQL error" % name, not errors, str(errors[:3])) + + failures = failed_commands(out) + R.check(group, "%s: no generated command failed (#errors empty)" % name, + not failures, + "%d command(s) threw, first: %s" % (len(failures), failures[:1])) + + rows = log_rows(out) + if expect_rows: + R.check(group, "%s: returned the #error_log result set" % name, + log_header_ok(out), "#error_log header missing or incomplete") + return rows + + +def smoke_tests(server, password, R): + """Structural assertions: the proc runs, emits a well-formed result set, + and honors @help / @debug.""" + grp = "Smoke" + + out, err = run_loghunter(server, password) + rows = clean_run(R, grp, "default run", out, err) + R.check(grp, "default run: returned at least one log row " + "(positive control -- the log is readable)", + len(rows) > 0, "no rows returned; is the error log empty?") + + malformed = [r for r in rows if len(r) < 4] + R.check(grp, "default run: every row well-formed (4 fields)", + not malformed, + "%d rows, %d malformed: %s" % (len(rows), len(malformed), malformed[:2])) + + # ---- @help = 1 ------------------------------------------------------- + out, err = run_loghunter(server, password, "@help = 1") + combined = out + "\n" + err + R.check(grp, "@help = 1: no severe SQL error", + not find_sql_errors(combined), str(find_sql_errors(combined)[:3])) + R.check(grp, "@help = 1: returns help text", + "i'm sp_LogHunter" in out or "sp_LogHunter" in out, "help text not found") + R.check(grp, "@help = 1: emits no log rows (short-circuits)", + not log_rows(out), "log rows emitted under @help") + + # ---- @debug = 1 ------------------------------------------------------ + # Scoped to a single custom search so the debug dump of #search is one row + # rather than ~90, keeping the output small without changing the path. + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 1, @debug = 1" % MARKER) + combined = out + "\n" + err + clean_run(R, grp, "@debug = 1", out, err, expect_rows=False) + missing = [m for m in DEBUG_MARKERS if m not in combined] + R.check(grp, "@debug = 1: prints diagnostics", + not missing, "missing debug markers: %s" % missing) + + +def canary_tests(server, password, R): + """Bidirectional proof that the search actually searches, using a real + marker written to the error log.""" + grp = "Canary" + + write_marker(server, password, MARKER) + + # PRESENT: the marker is found when searched for. This is the positive + # control that makes every absence assertion below non-vacuous. + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 1" % MARKER) + clean_run(R, grp, "marker search", out, err, expect_rows=False) + R.check(grp, "marker PRESENT when searched for (proves search works)", + marker_found(out, MARKER), + "marker %r not returned; the custom search found nothing" % MARKER) + + # ABSENT: a string never written is not found -- but the run is still + # clean, so this is a real negative rather than a crash. + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 1" % ABSENT_MARKER) + clean_run(R, grp, "never-written search", out, err, expect_rows=False) + R.check(grp, "never-written string ABSENT (search filters, not passes all)", + not marker_found(out, ABSENT_MARKER), + "a string that was never logged came back") + + # ABSENT from a default run: none of the ~88 canned strings match the + # marker, so a default run must not surface it. This proves @custom_message + # is what found it above, not the canned sweep. + out, err = run_loghunter(server, password) + rows_default = clean_run(R, grp, "default run (no custom message)", out, err) + R.check(grp, "marker ABSENT from a default run (canned searches do not match it)", + not marker_found(out, MARKER), + "marker surfaced without @custom_message; it collides with a canned string") + + # @custom_message_only = 0 runs BOTH the canned sweep and the custom + # search: the marker is found AND the canned results are still there. + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 0" % MARKER) + rows_both = clean_run(R, grp, "custom + canned", out, err) + R.check(grp, "@custom_message_only = 0: marker PRESENT (custom search ran)", + marker_found(out, MARKER), "marker missing when combined with canned sweep") + R.check(grp, "@custom_message_only = 0: canned sweep still ran " + "(more rows than the marker alone)", + len(rows_both) > 1, + "only %d row(s); the canned searches appear to have been skipped" + % len(rows_both)) + + # And the inverse: only = 1 must NOT run the canned sweep, so it returns + # strictly fewer rows than the combined run above. + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 1" % MARKER) + rows_only = log_rows(out) + R.check(grp, "@custom_message_only = 1: skips the canned sweep " + "(fewer rows than only = 0)", + len(rows_only) < len(rows_both), + "only=1 returned %d rows, only=0 returned %d -- the flag did nothing" + % (len(rows_only), len(rows_both))) + + +def date_range_tests(server, password, R): + """Bidirectional proof that the date arguments actually bound the search. + + Dates are computed server-side so no clock skew between this machine and + the instance can affect the result. + """ + grp = "DateRange" + + write_marker(server, password, MARKER) + + # A window that contains the write: the marker comes back. + out, err = run_loghunter( + server, password, + "@start_date = @s, @end_date = @e, @custom_message = N'%s', " + "@custom_message_only = 1" % MARKER, + preamble="DECLARE @s datetime = DATEADD(HOUR, -1, SYSDATETIME()), " + "@e datetime = DATEADD(HOUR, 1, SYSDATETIME()); ") + clean_run(R, grp, "window containing the write", out, err, expect_rows=False) + R.check(grp, "marker PRESENT inside its window (proves date args work)", + marker_found(out, MARKER), + "marker not returned for a window that contains it") + + # A window 30 days in the past: the same marker must NOT come back. The + # assertion above proves this run is capable of finding it, so this is a + # real filter test rather than an empty result set. + out, err = run_loghunter( + server, password, + "@start_date = @s, @end_date = @e, @custom_message = N'%s', " + "@custom_message_only = 1" % MARKER, + preamble="DECLARE @s datetime = DATEADD(DAY, -30, SYSDATETIME()), " + "@e datetime = DATEADD(DAY, -29, SYSDATETIME()); ") + clean_run(R, grp, "window before the write", out, err, expect_rows=False) + R.check(grp, "marker ABSENT outside its window (date range actually filters)", + not marker_found(out, MARKER), + "marker returned for a window 30 days before it was written") + + # @first_log_only = 1 still finds a marker written moments ago, since the + # current log is log 0. Proves the flag narrows scope without breaking it. + out, err = run_loghunter( + server, password, + "@first_log_only = 1, @custom_message = N'%s', " + "@custom_message_only = 1" % MARKER) + clean_run(R, grp, "@first_log_only = 1", out, err, expect_rows=False) + R.check(grp, "@first_log_only = 1: still finds a marker in the current log", + marker_found(out, MARKER), + "marker missing when restricted to the first log") + + +def generated_command_tests(server, password, R): + """The core matrix. Every case below changes how the xp_readerrorlog + command string is built; each asserts the generated command EXECUTED + cleanly (#errors empty). Several are direct regression tests for bugs + documented in the source.""" + grp = "Generated" + + cases = [ + # (label, args, note) + ("@days_back = 0 (normalized to -1)", "@days_back = 0", ""), + ("@days_back positive (normalized to -7)", "@days_back = 7", ""), + # Regression: an absurd value once overflowed DATEADD and killed the run. + ("@days_back absurd (clamped to -36500)", "@days_back = -99999", ""), + ("@days_back = -1", "@days_back = -1", ""), + ("@first_log_only = 1", "@first_log_only = 1", ""), + ("@language_id = 1033 explicit", "@language_id = 1033", ""), + ] + for label, args, _note in cases: + out, err = run_loghunter(server, password, args) + clean_run(R, grp, label, out, err) + + # Regression: in date-range mode @days_back is retired to NULL, and the + # canary rows then had to fall back to @start_date for their floor. When + # that fallback was missing the generated command received a NULL date and + # threw -- straight into #errors, invisible to the caller. All three + # date-range shapes are covered because the proc fills in whichever date + # the caller omitted. + date_cases = [ + ("@start_date only", + "@start_date = @s", + "DECLARE @s datetime = DATEADD(DAY, -3, SYSDATETIME()); "), + ("@end_date only", + "@end_date = @e", + "DECLARE @e datetime = SYSDATETIME(); "), + ("@start_date and @end_date", + "@start_date = @s, @end_date = @e", + "DECLARE @s datetime = DATEADD(DAY, -3, SYSDATETIME()), " + "@e datetime = SYSDATETIME(); "), + ] + for label, args, preamble in date_cases: + out, err = run_loghunter(server, password, args, preamble=preamble) + clean_run(R, grp, "date-range mode: %s" % label, out, err) + + # Regression: a literal " inside @custom_message closed the quoted + # xp_readerrorlog argument early and produced "Incorrect syntax near '+'". + # The command is built by concatenation, so this is a parse-time failure of + # the GENERATED batch -- exactly what #errors captures. + write_marker(server, password, QUOTE_MARKER) + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 1" % _esc(QUOTE_MARKER)) + clean_run(R, grp, "@custom_message containing a double quote", out, err, + expect_rows=False) + R.check(grp, "@custom_message with a double quote still MATCHES " + "(quotes doubled, not dropped)", + marker_found(out, QUOTE_MARKER), + "quoted marker not returned; the quote handling changed its meaning") + + # A single quote is escaped on the T-SQL side rather than the + # xp_readerrorlog side, so it exercises a different path. + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 1" + % _esc("DarlingData's LogHunter")) + clean_run(R, grp, "@custom_message containing a single quote", out, err, + expect_rows=False) + + # Regression: a @custom_message longer than 128 characters. While search + # strings were wrapped in double quotes they parsed as identifiers, which + # SQL Server caps at 128 characters, so anything longer died with Msg 103 + # ("The identifier that starts with ... is too long") -- into #errors, + # never onto the caller's screen. Asserting only that it does not throw + # would be too weak, so the marker is written to the log first and must + # come back: proof that a long search actually searches. + write_marker(server, password, LONG_MARKER) + out, err = run_loghunter( + server, password, + "@custom_message = N'%s', @custom_message_only = 1" % _esc(LONG_MARKER)) + clean_run(R, grp, "@custom_message longer than 128 chars", out, err, + expect_rows=False) + R.check(grp, "@custom_message of %d chars MATCHES " + "(no 128-char identifier limit)" % len(LONG_MARKER), + marker_found(out, LONG_MARKER), + "long marker not returned; the search string hit a length limit") + + +def validation_tests(server, password, R): + """The guard paths that RAISERROR and RETURN before doing any work.""" + grp = "Validation" + + # Invalid @language_id: rejected up front. + out, err = run_loghunter(server, password, "@language_id = 999999") + combined = out + "\n" + err + R.check(grp, "@language_id invalid: rejected with a message", + "not a valid language_id" in combined, + "expected language_id rejection, got: %r" % combined[:200]) + R.check(grp, "@language_id invalid: returns no log rows", + not log_rows(out), "log rows emitted despite invalid language_id") + + # @custom_message_only = 1 with nothing to search for: rejected, because + # it would otherwise leave #search empty and make the whole run a no-op + # that reports a clean bill of health. + for label, args in [ + ("NULL @custom_message", "@custom_message_only = 1"), + ("empty @custom_message", + "@custom_message_only = 1, @custom_message = N''"), + ]: + out, err = run_loghunter(server, password, args) + combined = out + "\n" + err + R.check(grp, "@custom_message_only = 1 with %s: rejected" % label, + "requires a non-empty @custom_message" in combined, + "expected rejection, got: %r" % combined[:200]) + R.check(grp, "@custom_message_only = 1 with %s: returns no log rows" % label, + not log_rows(out), "log rows emitted despite rejection") + + # A valid non-English language_id warns rather than silently returning + # nothing, because the search strings are English literals. Only asserted + # when the instance actually has a non-English message set installed -- + # most containers ship 1033 only, and asserting unconditionally would fail + # for a reason that has nothing to do with the procedure. + out, _ = _sqlcmd( + server, password, + "SET NOCOUNT ON; SELECT TOP (1) CONVERT(varchar(10), m.language_id) " + "FROM sys.messages AS m WHERE m.language_id <> 1033 " + "GROUP BY m.language_id;", + headers=False) + other = next((l.strip() for l in out.splitlines() if l.strip().isdigit()), None) + if other: + out, err = run_loghunter(server, password, "@language_id = %s" % other) + combined = out + "\n" + err + R.check(grp, "non-English @language_id warns instead of returning silence", + "will not translate" in combined, + "no warning for language_id %s" % other) + + +def preflight(server, password): + out, _ = _sqlcmd( + server, password, + "SET NOCOUNT ON; SELECT CASE WHEN OBJECT_ID(N'dbo.sp_LogHunter', N'P') " + "IS NOT NULL THEN 'OK' ELSE 'MISSING' END;", + headers=False, + ) + return "OK" in out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server", default="SQL2022") + ap.add_argument("--password", default="L!nt0044") + args = ap.parse_args() + + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + print("Running sp_LogHunter assertion tests against %s..." % args.server) + print() + + if not preflight(args.server, args.password): + print("ERROR: dbo.sp_LogHunter is not installed in master on %s." % args.server) + print("Install sp_LogHunter.sql before running this harness.") + sys.exit(1) + + R = Results() + smoke_tests(args.server, args.password, R) + canary_tests(args.server, args.password, R) + date_range_tests(args.server, args.password, R) + generated_command_tests(args.server, args.password, R) + validation_tests(args.server, args.password, R) + + for r in R.items: + status = "PASS" if r["passed"] else "FAIL" + detail = (" (%s)" % r["detail"]) if (not r["passed"] and r["detail"]) else "" + print(" [%s] %s: %s%s" % (status, r["group"], r["name"], detail)) + + print() + print("Results: %d passed, %d failed, %d total" % (R.passed, R.failed, len(R.items))) + + if R.failed > 0: + print() + print("FAILED TESTS:") + for r in R.items: + if not r["passed"]: + print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"])) + sys.exit(1) + else: + print("All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/sp_PerfCheck/README.md b/sp_PerfCheck/README.md index dfa77cd8..eda676f1 100644 --- a/sp_PerfCheck/README.md +++ b/sp_PerfCheck/README.md @@ -23,22 +23,42 @@ | Parameter | Data Type | Default | Description | |-----------|-----------|---------|-------------| | @database_name | sysname | NULL | Specific database to check; NULL checks all accessible user databases | +| @slow_read_ms | decimal(10, 2) | 20.0 | Flag data-file reads slower than this many ms; High at 5x this value | +| @slow_write_ms | decimal(10, 2) | 20.0 | Flag data-file writes slower than this many ms; High at 5x this value | +| @significant_wait_threshold_pct | decimal(38, 2) | 10.0 | Minimum percent of uptime for a wait to be reported | +| @wait_high_pct | decimal(38, 2) | 50.0 | A resource wait at or above this percent of uptime is High priority | +| @wait_medium_pct | decimal(38, 2) | 20.0 | A resource wait at or above this percent of uptime is Medium priority | +| @memory_grant_warning | integer | 100 | Forced memory grants at or above this cumulative count are Medium | +| @memory_grant_critical | integer | 10000 | Forced memory grants at or above this cumulative count are High | +| @help | bit | 0 | Displays help information | | @debug | bit | 0 | Print diagnostic messages and intermediate query results | | @version | varchar(30) | NULL OUTPUT | Returns version number | | @version_date | datetime | NULL OUTPUT | Returns version date | +The wait percentages are measured against server uptime and are deliberately wide +(`decimal(38, 2)`): cumulative wait time across all schedulers routinely exceeds +100% of wall-clock uptime on a many-core server. + +Any of the threshold parameters left NULL or set to a negative number falls back +to its default, so you can override only the ones you care about. + ## Usage ```sql --- Basic check on all databases -EXEC dbo.sp_PerfCheck; +/* Basic check on all databases */ +EXECUTE dbo.sp_PerfCheck; --- Check a specific database only -EXEC dbo.sp_PerfCheck +/* Check a specific database only */ +EXECUTE dbo.sp_PerfCheck @database_name = 'YourDatabaseName'; --- Run with debug information -EXEC dbo.sp_PerfCheck +/* Loosen the storage thresholds for a busy, latency-tolerant system */ +EXECUTE dbo.sp_PerfCheck + @slow_read_ms = 50.0, + @slow_write_ms = 50.0; + +/* Run with debug information */ +EXECUTE dbo.sp_PerfCheck @debug = 1; ``` @@ -48,8 +68,8 @@ All findings are assigned a priority level indicating severity and urgency: | Priority | Label | Meaning | |----------|-------|---------| -| 10 | **Critical** | Server instability — crashes, offline resources, pending configuration changes | -| 20 | **High** | Active performance degradation — severe I/O latency, memory pressure, high deadlock rates | +| 10 | **Critical** | Server instability: crashes, offline resources, pending configuration changes | +| 20 | **High** | Active performance degradation: severe I/O latency, memory pressure, high deadlock rates | | 30 | **Medium** | Moderate impact or risky configuration that will likely cause problems | | 40 | **Low** | Best practice recommendations that improve reliability | | 50 | **Informational** | Awareness items and non-default settings that may be intentional | @@ -62,17 +82,25 @@ Results include a `priority_label` column for readability and are sorted by prio | Check | Finding | Priority | Description | |-------|---------|----------|-------------| -| 1000 | Non-Default Configuration | Informational (50) | Reports sp_configure options changed from default | -| 1001 | Min Memory Too Close To Max | Low (40) | Min server memory >= 90% of max | -| 1003 | MAXDOP Not Configured | Low (40) | Default MAXDOP (0) on multi-processor system | -| 1004 | Low Cost Threshold | Low (40) | Cost threshold for parallelism <= 5 | +| 1001 | Min Server Memory Too Close To Max | Low (40) | Min server memory >= 90% of max server memory | +| 1002 | Max Server Memory Too Close To Physical Memory | High (20) | Max server memory >= 95% of physical memory, starving the OS | +| 1003 | MAXDOP Not Configured | Low (40) | Default MAXDOP (0) on a server with more than 8 logical processors | +| 1004 | Cost Threshold for Parallelism Too Low | High (20) / Low (40) | Fires below 50; High when still at or under the default of 5 | | 1005 | Priority Boost Enabled | High (20) | Dangerous setting affecting Windows scheduling | | 1006 | Lightweight Pooling Enabled | Low (40) | Fiber mode rarely beneficial | -| 1007 | Config Pending Reconfigure | Critical (10) | Server not running intended configuration | +| 1007 | Configuration Pending Reconfigure | Critical (10) | Server not running its intended configuration | | 1008 | Affinity Mask Configured | Informational (50) | Manual CPU binding | | 1009 | Affinity I/O Mask Configured | Informational (50) | Manual I/O CPU binding | | 1010 | Affinity64 Mask Configured | Informational (50) | CPU binding for processors 33-64 | | 1011 | Affinity64 I/O Mask Configured | Informational (50) | I/O binding for processors 33-64 | +| 1012 | Notable Global Trace Flag | High (20) / Medium (30) / Low (40) | Interprets notable global trace flags; see below | + +Check 1012 reports on global trace flags 1211, 1224, 3608, 3609, 834, 4199, 8048, +1117, 1118, and 2371, with an explanation of what each one does and whether it is +still needed on modern versions. Flags 1211, 3608, and 3609 are High; 1224 and 834 +are Medium; the rest are Low. The complete list of enabled global trace flags is +always reported in the server information result set, so benign flags are visible +without being raised as findings. ### TempDB Configuration @@ -81,50 +109,78 @@ Results include a `priority_label` column for readability and are sorted by prio | 2001 | Single TempDB Data File | Medium (30) | Single file causes allocation contention | | 2002 | Odd Number of TempDB Files | Informational (50) | File count not optimal for CPU count | | 2003 | More TempDB Files Than CPUs | Informational (50) | More data files than logical processors | -| 2004 | Uneven TempDB File Sizes | Low (40) | Data files vary in size by >10% | -| 2005 | Mixed TempDB Autogrowth | Low (40) | Inconsistent growth settings across files | -| 2006 | Percentage Growth in TempDB | Low (40) | Percentage-based growth in TempDB files | -| 2010 | TempDB Allocation Contention | Medium (30) | Active pagelatch contention detected | +| 2004 | Uneven TempDB Data File Sizes | Low (40) | Data files vary in size by more than 10% | +| 2005 | Mixed TempDB Autogrowth Settings | Low (40) | Inconsistent growth settings across files | +| 2006 | Percentage Auto-Growth Setting in TempDB | Low (40) | Percentage-based growth in TempDB files | +| 2010 | TempDB Allocation Contention Detected | Medium (30) | Active pagelatch contention detected | ### Storage Performance | Check | Finding | Priority | Description | |-------|---------|----------|-------------| -| 3001 | Slow Read Latency | High (20) / Medium (30) | >1000 ms = High, >500 ms = Medium per file | -| 3002 | Slow Write Latency | High (20) / Medium (30) | >1000 ms = High, >500 ms = Medium per file | -| 3003 | Multiple Slow Files on Storage Location | High (20) | Systemic storage problem on a drive | +| 3001 | Slow Read Latency | High (20) / Medium (30) | Medium above @slow_read_ms (20 ms default), High above 5x that (100 ms default) | +| 3002 | Slow Write Latency | High (20) / Medium (30) | Medium above @slow_write_ms (20 ms default), High above 5x that (100 ms default) | +| 3003 | Multiple Slow Files on Storage Location | High (20) | More than one slow file sharing a drive or volume | + +Checks 3001 and 3002 only fire for files with more than 1,000 reads or writes +respectively, so a barely-used file cannot trip them on a handful of slow samples. ### Server Health | Check | Finding | Priority | Description | |-------|---------|----------|-------------| | 4001 | Offline CPU Schedulers | Critical (10) | CPUs offline, reducing processing power | -| 4101 | Memory-Starved Queries (forced) | High (20) | Forced grants causing tempdb spills | -| 4102 | Memory Dumps Detected | Critical (10) | Server crashing in last 90 days | -| 4103 | Memory Grant Timeouts | High (20) | Queries can't get memory | -| 4104 | Large Security Token Cache | High/Medium/Low | >5 GB=20, >2 GB=30, >1 GB=40 | -| 4105 | Lock Pages Not Enabled | Low (40) | Best practice for >=32 GB RAM | -| 4106 | IFI Disabled | Low (40) | Best practice for file operations | -| 4107 | Resource Governor Enabled | Informational (50) | May be intentional | +| 4101 | Memory-Starved Queries: Forced Grants | High (20) / Medium (30) / Low (40) | Graduated on @memory_grant_critical (10000) and @memory_grant_warning (100) | +| 4102 | Memory Dumps Detected In Last 90 Days | Critical (10) | Server crashing in the last 90 days | +| 4103 | Memory-Starved Queries: Grant Timeouts | High (20) / Medium (30) / Low (40) | More than 100 = High, more than 10 = Medium, otherwise Low | +| 4104 | Large Security Token Cache | High (20) / Medium (30) / Low (40) | Over 5 GB = High, over 2 GB = Medium, fires at 1 GB | +| 4105 | Lock Pages in Memory Not Enabled | Low (40) | Best practice for servers with 32 GB or more of RAM | +| 4106 | Instant File Initialization Disabled | Low (40) | Best practice for file creation and growth operations | + +Checks 4105 and 4106 are on-premises only. Azure SQL Managed Instance and AWS RDS +do not expose the underlying Windows user rights, so flagging them there would be +unactionable. ### Trace Events | Check | Finding | Priority | Description | |-------|---------|----------|-------------| -| 5001 | Slow Auto-Growth | Medium (30) / Low (40) | Log grows = Medium, data grows = Low | -| 5002 | Auto-Shrink Events | Low (40) | Harmful config executing | -| 5003 | Disruptive DBCC Commands | Medium (30) / Informational (50) | Destructive = Medium, other = Informational | -| 5103 | High Deadlock Rate | High (20) / Medium (30) | >50/day = High, >9/day = Medium | +| 5000 | Inadequate permissions | Informational (50) | `sys.traces` is not readable by the current login, so trace checks are skipped | +| 5001 | Slow Data File / Log File Auto Grow | Medium (30) / Low (40) | Log grows = Medium, data grows = Low | +| 5002 | Data File / Log File Auto Shrink | Low (40) | Harmful config actually executing | +| 5003 | Potentially Disruptive DBCC Commands | Medium (30) / Informational (50) | Cache-clearing and WRITEPAGE = Medium, other = Informational | +| 5004 | Default Trace Not Readable | Informational (50) | Default trace is registered but cannot be read; expected on Linux | +| 5103 | High Number of Deadlocks | High (20) / Medium (30) | More than 50/day = High, more than 9/day = Medium | + +Checks 5001 through 5003 read the default trace and look back at most 7 days. +Check 5004 exists so that a server where the default trace cannot be read is +distinguishable from a server that genuinely has nothing to report: on Linux, +`fn_trace_gettable` cannot read the default trace at all. ### Resource Performance | Check | Finding | Priority | Description | |-------|---------|----------|-------------| -| 6001 | High Impact Wait Types | High (20) / Medium (30) / Low (40) | Top 10 waits by % of uptime | -| 6002 | High Stolen Memory | High (20) / Medium (30) / Low (40) | Buffer pool starvation | -| 6003 | Top Memory Consumers | Informational (50) | Top 5 non-buffer pool memory clerks | -| 6101 | High Signal Wait Ratio | High (20) / Medium (30) / Low (40) | CPU scheduler contention | -| 6102 | High SOS_SCHEDULER_YIELD | High (20) / Medium (30) / Low (40) | CPU pressure from frequent yields | +| 6001 | Per-category wait findings | High (20) / Medium (30) / Low (40) | Top 10 waits by percent of uptime; see below | +| 6002 | High Stolen Memory Percentage | High (20) / Medium (30) / Low (40) | Over 30% = High, over 15% = Medium | +| 6003 | Top Memory Consumer | Informational (50) | Top 5 non-buffer-pool memory clerks using 1 GB or more | +| 6101 | High Signal Wait Ratio | High (20) / Medium (30) / Low (40) | CPU scheduler contention; 50% = High, 30% = Medium, fires at 25% | +| 6102 | High SOS_SCHEDULER_YIELD Waits | High (20) / Medium (30) / Low (40) | CPU pressure from frequent yields; same 50/30/25 thresholds | + +Check 6001 does not emit a generic "high wait" finding. It names each finding by +what the wait means for the server: `Storage-Related Waits`, `Memory-Related Waits`, +`Lock / Blocking Waits`, `TempDB Contention Waits`, `Transaction Log Waits`, +`CPU / Scheduling Waits`, `Parallelism Waits`, `Query Execution Waits`, +`Network / Client Waits`, `Availability Group Waits`, `Azure SQL Throttling Waits`, +`Index Maintenance Waits`, `Statistics Update Waits`, or `Other Significant Waits`. +The specific wait type and its plain-English meaning go in `object_name`. + +Severity is calibrated by category rather than applied flat. Resource-pressure +waits (locking, memory, storage, tempdb, transaction log, CPU) reach High at +`@wait_high_pct` of uptime or an average wait of 1 second, and Medium at +`@wait_medium_pct` or an average wait of 250 ms. Parallelism waits are usually a +cost threshold or MAXDOP symptom, so they need 100% of uptime just to reach +Medium. Everything else stays Low. ### Database Configuration @@ -132,38 +188,40 @@ Results include a `priority_label` column for readability and are sorted by prio |-------|---------|----------|-------------| | 7001 | Auto-Shrink Enabled | Medium (30) | Actively harmful config | | 7002 | Auto-Close Enabled | Low (40) | Causes connection delays | -| 7003 | Restricted Access Mode | High (20) | Apps can't connect | -| 7004 | Auto Stats Disabled | Medium (30) | Causes stale statistics | -| 7005 | ANSI Settings | Informational (50) | Non-standard ANSI settings | +| 7003 | Restricted Access Mode | High (20) | Database is not in MULTI_USER mode, so apps cannot connect | +| 7004 | Auto Create / Update Statistics Disabled | Medium (30) | Causes missing or stale statistics | | 7006 | Query Store Not Enabled | Informational (50) | Missed opportunity | -| 7007 | Non-Default Recovery Time | Informational (50) | Awareness | | 7008 | Delayed Durability | Medium (30) | Data loss risk on crash | -| 7009 | ADR Not Enabled | Low (40) | Recommendation for SI/RCSI databases | +| 7009 | Accelerated Database Recovery Not Enabled With Snapshot Isolation | Low (40) | Recommendation for SI/RCSI databases; SQL Server 2019+ only | | 7010 | Ledger Feature Enabled | Informational (50) | Awareness of overhead | -| 7011 | Query Store State Mismatch | Medium (30) | QS not working as intended | -| 7012 | Query Store Suboptimal Config | Low (40) | Tuning recommendation | -| 7020 | Non-Default DB Scoped Config | Informational (50) | Awareness | +| 7011 | Query Store State Mismatch | Medium (30) | Query Store is not in the state it was asked to be in | +| 7012 | Query Store Suboptimal Configuration | Low (40) | Tuning recommendation | +| 7020 | Non-Default Database Scoped Configuration | Informational (50) | Awareness | ### Database File Settings | Check | Finding | Priority | Description | |-------|---------|----------|-------------| -| 7101 | % Growth on Data File | Low (40) | Reports growth % and current file size | -| 7102 | % Growth on Log File | Medium (30) | Reports growth % and current file size | -| 7103 | Non-Optimal Log Growth | Low (40) | Not 64 MB on SQL 2022+/Azure | -| 7104 | Extremely Large Growth | Low (40) | Fixed growth >10 GB | +| 7101 | Percentage Auto-Growth Setting on Data File | Low (40) | Reports growth percent and current file size | +| 7102 | Percentage Auto-Growth Setting on Log File | Medium (30) | Reports growth percent and current file size | +| 7103 | Non-Optimal Log File Growth Increment | Low (40) | Not exactly 64 MB on SQL Server 2022+, Azure SQL DB, or Azure MI | +| 7104 | Extremely Large Auto-Growth Setting | Low (40) | Fixed growth over 10 GB | ## Results Organization Results are organized by check_id ranges: -- **1000-series**: Server configuration settings -- **2000-series**: TempDB configuration +- **1000-series**: Server configuration settings and global trace flags +- **2000-series**: TempDB configuration and contention - **3000-series**: Storage performance (file-level I/O) - **4000-series**: Server health (memory, CPU, stability) -- **5000-series**: Trace events (auto-growth, deadlocks, DBCC) -- **6000-series**: Resource performance (waits, I/O, memory) -- **7000-series**: Database configuration +- **5000-series**: Default trace events (auto-growth, auto-shrink, DBCC) and deadlocks +- **6000-series**: Resource performance (waits, CPU scheduling, memory) +- **7000-series**: Database configuration, with 7100-series covering database file settings + +Findings in the `Errors` category are not health checks. They record that a +collection step could not run (missing permissions, an unreadable DMV) so that a +skipped check is never mistaken for a clean result. Results are returned in two result sets: diff --git a/sp_PerfCheck/sp_PerfCheck.sql b/sp_PerfCheck/sp_PerfCheck.sql index 80c250a2..c1529a42 100644 --- a/sp_PerfCheck/sp_PerfCheck.sql +++ b/sp_PerfCheck/sp_PerfCheck.sql @@ -50,6 +50,21 @@ ALTER PROCEDURE dbo.sp_PerfCheck ( @database_name sysname = NULL, /* Database to check, NULL for all user databases */ + @slow_read_ms decimal(10, 2) = 20.0, /* Flag data-file reads slower than this (ms); High at 5x */ + @slow_write_ms decimal(10, 2) = 20.0, /* Flag data-file writes slower than this (ms); High at 5x */ + /* + decimal(38, 2), not decimal(5, 2). These compare against + #wait_stats.wait_time_percent_of_uptime, which is decimal(38, 2) because + cumulative wait time across all schedulers routinely runs to several + hundred or several thousand percent of wall-clock uptime on a many-core + server. decimal(5, 2) caps an override at 999.99, which cannot express a + threshold in the range the column actually reports. + */ + @significant_wait_threshold_pct decimal(38, 2) = 10.0, /* Minimum % of uptime for a wait to be reported */ + @wait_high_pct decimal(38, 2) = 50.0, /* Resource wait at/above this % of uptime is High */ + @wait_medium_pct decimal(38, 2) = 20.0, /* Resource wait at/above this % of uptime is Medium */ + @memory_grant_warning integer = 100, /* Forced grants at/above this count are Medium */ + @memory_grant_critical integer = 10000, /* Forced grants at/above this count are High */ @help bit = 0, /*For helpfulness*/ @debug bit = 0, /* Print diagnostic messages */ @version varchar(30) = NULL OUTPUT, /* Returns version */ @@ -66,8 +81,8 @@ BEGIN Set version information */ SELECT - @version = N'2.7', - @version_date = N'20260701'; + @version = N'2.8', + @version_date = N'20260801'; /* Help section, for help. @@ -103,6 +118,13 @@ BEGIN WHEN N'@debug' THEN 'prints debug information during execution' WHEN N'@version' THEN 'returns the version number of the procedure' WHEN N'@version_date' THEN 'returns the date this version was released' + WHEN N'@slow_read_ms' THEN 'flag data-file reads slower than this many ms (High at 5x)' + WHEN N'@slow_write_ms' THEN 'flag data-file writes slower than this many ms (High at 5x)' + WHEN N'@significant_wait_threshold_pct' THEN 'minimum percent of uptime for a wait to be reported' + WHEN N'@wait_high_pct' THEN 'a resource wait at or above this percent of uptime is High priority' + WHEN N'@wait_medium_pct' THEN 'a resource wait at or above this percent of uptime is Medium priority' + WHEN N'@memory_grant_warning' THEN 'forced memory grants at or above this cumulative count are Medium' + WHEN N'@memory_grant_critical' THEN 'forced memory grants at or above this cumulative count are High' ELSE NULL END, valid_inputs = @@ -113,6 +135,13 @@ BEGIN WHEN N'@debug' THEN '0 or 1' WHEN N'@version' THEN 'OUTPUT parameter' WHEN N'@version_date' THEN 'OUTPUT parameter' + WHEN N'@slow_read_ms' THEN 'any positive number of milliseconds' + WHEN N'@slow_write_ms' THEN 'any positive number of milliseconds' + WHEN N'@significant_wait_threshold_pct' THEN 'any positive percentage' + WHEN N'@wait_high_pct' THEN 'any positive percentage' + WHEN N'@wait_medium_pct' THEN 'any positive percentage' + WHEN N'@memory_grant_warning' THEN 'any positive integer' + WHEN N'@memory_grant_critical' THEN 'any positive integer' ELSE NULL END, defaults = @@ -123,6 +152,13 @@ BEGIN WHEN N'@debug' THEN 'false' WHEN N'@version' THEN 'NULL' WHEN N'@version_date' THEN 'NULL' + WHEN N'@slow_read_ms' THEN '20.0' + WHEN N'@slow_write_ms' THEN '20.0' + WHEN N'@significant_wait_threshold_pct' THEN '10.0' + WHEN N'@wait_high_pct' THEN '50.0' + WHEN N'@wait_medium_pct' THEN '20.0' + WHEN N'@memory_grant_warning' THEN '100' + WHEN N'@memory_grant_critical' THEN '10000' ELSE NULL END FROM sys.all_parameters AS ap @@ -167,6 +203,55 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. RETURN; END; + /* + Default any tuning threshold left NULL or negative back to its documented + value, so a caller can override only the ones they care about. A CASE on + ">= 0" catches both NULL (UNKNOWN) and negative in one shot. + */ + SELECT + @slow_read_ms = + CASE + WHEN @slow_read_ms >= 0 + THEN @slow_read_ms + ELSE 20.0 + END, + @slow_write_ms = + CASE + WHEN @slow_write_ms >= 0 + THEN @slow_write_ms + ELSE 20.0 + END, + @significant_wait_threshold_pct = + CASE + WHEN @significant_wait_threshold_pct >= 0 + THEN @significant_wait_threshold_pct + ELSE 10.0 + END, + @wait_high_pct = + CASE + WHEN @wait_high_pct >= 0 + THEN @wait_high_pct + ELSE 50.0 + END, + @wait_medium_pct = + CASE + WHEN @wait_medium_pct >= 0 + THEN @wait_medium_pct + ELSE 20.0 + END, + @memory_grant_warning = + CASE + WHEN @memory_grant_warning >= 0 + THEN @memory_grant_warning + ELSE 100 + END, + @memory_grant_critical = + CASE + WHEN @memory_grant_critical >= 0 + THEN @memory_grant_critical + ELSE 10000 + END; + /* Variable Declarations */ @@ -282,9 +367,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. @size_difference_pct decimal(18, 2), @has_percent_growth bit, @has_fixed_growth bit, - /* Storage performance variables */ - @slow_read_ms decimal(10, 2) = 500.0, /* Threshold for slow reads (ms) */ - @slow_write_ms decimal(10, 2) = 500.0, /* Threshold for slow writes (ms) */ /* Set threshold for "slow" autogrowth (in ms) */ @slow_autogrow_ms integer = 1000, /* 1 second */ @trace_path nvarchar(260), @@ -292,7 +374,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. /* Determine total waits, uptime, and significant waits */ @total_waits bigint, @uptime_ms bigint, - @significant_wait_threshold_pct decimal(5, 2) = 10.0, /* Only waits above 10% */ @significant_wait_threshold_avg decimal(10, 2) = 10.0, /* Or avg wait time > 10ms */ /* Threshold settings for stolen memory alert */ @buffer_pool_size_gb decimal(38, 2), @@ -391,6 +472,46 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. is_aws_rds = @aws_rds; END; + /* + Validate @database_name up front, before any check runs. If the + caller named a database that does not exist (a typo), or one that is + not ONLINE (mid-restore, RECOVERY_PENDING, SUSPECT, a log-shipping + standby), the per-database checks match no rows and the report comes + back with no database findings and no error - reading as "this + database is clean" when it was never looked at. Fail loudly here + rather than running the whole server sweep and then returning empty. + Azure SQL DB is exempt: it always analyzes the current database via + DB_ID() and ignores the parameter. + */ + IF @database_name IS NOT NULL + AND @azure_sql_db = 0 + BEGIN + IF NOT EXISTS + ( + SELECT + 1/0 + FROM sys.databases AS d + WHERE d.name = @database_name + ) + BEGIN + RAISERROR(N'The database %s does not exist on this server. Check the name and try again.', 11, 1, @database_name) WITH NOWAIT; + RETURN; + END; + + IF NOT EXISTS + ( + SELECT + 1/0 + FROM sys.databases AS d + WHERE d.name = @database_name + AND d.state = 0 /* ONLINE */ + ) + BEGIN + RAISERROR(N'The database %s exists but is not ONLINE, so its configuration cannot be analyzed. Run without @database_name for the server-level checks.', 11, 1, @database_name) WITH NOWAIT; + RETURN; + END; + END; + /* Create a table for stuff I care about from sys.databases With comments on what we want to check @@ -427,7 +548,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. is_cdc_enabled bit NOT NULL, target_recovery_time_in_seconds integer NULL, delayed_durability_desc nvarchar(60) NULL, - is_accelerated_database_recovery_on bit NOT NULL, + /* + Nullable on purpose. A database that is closed (AUTO_CLOSE), OFFLINE, or + otherwise inaccessible returns NULL for is_accelerated_database_recovery_on + in sys.databases - ADR state lives with the database, not in master + metadata, so it can't be read while the database is shut. The collection + below has no state filter, so a single inaccessible database would + otherwise fail this INSERT with Msg 515 and abort the whole check. NULL + flows through harmlessly: the ADR finding tests "= 0", which NULL never + satisfies, so a database we cannot assess simply gets no recommendation. + */ + is_accelerated_database_recovery_on bit NULL, is_ledger_on bit NULL ); @@ -456,7 +587,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. category nvarchar(50) NOT NULL, finding nvarchar(200) NOT NULL, database_name sysname NOT NULL DEFAULT N'N/A', - object_name sysname NOT NULL DEFAULT N'N/A', + /* + Wide, not sysname. The storage checks (3001, 3002, 3003) put a file or + drive location in here: drive_location and physical_name are both + nvarchar(260) and hold the full blob storage URL for a data file on + Azure, and 3001/3002 append N' (' + type_desc + N')' on top of that. + sysname truncates at 128 characters and even 260 is not enough for the + longest path plus its suffix. Overflow raises Msg 8152 inside the outer + TRY, which kills the entire run rather than just the storage findings. + */ + object_name nvarchar(500) NOT NULL DEFAULT N'N/A', details nvarchar(4000) NULL, url nvarchar(200) NULL ); @@ -585,7 +725,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. avg_wait_ms AS (wait_time_ms / NULLIF(waiting_tasks_count, 0)), percentage decimal(5, 2) NOT NULL, signal_wait_time_ms bigint NOT NULL, - wait_time_percent_of_uptime decimal(6, 2) NULL, + /* + decimal(38, 2), not decimal(6, 2). This is cumulative wait time + across all schedulers as a percentage of wall-clock uptime, so + on a many-core server it routinely runs to several hundred or + several thousand percent (64 cores fully waiting is ~6400%). + decimal(6, 2) caps at 9999.99, and the overflow THREW out of the + whole procedure - no health findings at all - on exactly the + busy, high-core servers that most need checking. + */ + wait_time_percent_of_uptime decimal(38, 2) NULL, category nvarchar(50) NOT NULL ); @@ -913,14 +1062,21 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ) SELECT check_id = 4101, - priority = 20, /* High: active memory spills */ + priority = + CASE + WHEN MAX(ders.forced_grant_count) >= @memory_grant_critical + THEN 20 /* High: heavy, sustained memory pressure */ + WHEN MAX(ders.forced_grant_count) >= @memory_grant_warning + THEN 30 /* Medium */ + ELSE 40 /* Low: a handful since startup, likely transient */ + END, category = N'Memory Pressure', - finding = N'Memory-Starved Queries Detected', + finding = N'Memory-Starved Queries: Forced Grants', details = - N'dm_exec_query_resource_semaphores has ' + + N'dm_exec_query_resource_semaphores reports ' + CONVERT(nvarchar(10), MAX(ders.forced_grant_count)) + - N' forced memory grants. ' + - N'Queries are being forced to run with less memory than requested, which can cause spills to tempdb and poor performance.', + N' forced memory grants since startup. Queries ran with less memory than they asked for and spilled to tempdb. ' + + N'Review oversized grants (large sorts and hashes), query tuning, and max server memory.', url = N'https://erikdarling.com/sp_perfcheck/#MemoryStarved' FROM sys.dm_exec_query_resource_semaphores AS ders WHERE ders.forced_grant_count > 0 @@ -940,14 +1096,21 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ) SELECT check_id = 4103, - priority = 20, /* High: queries can't get memory */ + priority = + CASE + WHEN MAX(ders.timeout_error_count) > 100 + THEN 20 /* High: queries repeatedly failing to get memory */ + WHEN MAX(ders.timeout_error_count) > 10 + THEN 30 /* Medium */ + ELSE 40 /* Low: a few since startup, likely transient */ + END, category = N'Memory Pressure', - finding = N'Memory-Starved Queries Detected', + finding = N'Memory-Starved Queries: Grant Timeouts', details = - N'dm_exec_query_resource_semaphores has ' + + N'dm_exec_query_resource_semaphores reports ' + CONVERT(nvarchar(10), MAX(ders.timeout_error_count)) + - N' memory grant timeouts. ' + - N'Queries are waiting for memory for a long time and giving up.', + N' memory grant timeouts since startup. Queries waited a long time for a memory grant and gave up (error 8645). ' + + N'Review oversized grants, RESOURCE_SEMAPHORE waits, and max server memory.', url = N'https://erikdarling.com/sp_perfcheck/#MemoryStarved' FROM sys.dm_exec_query_resource_semaphores AS ders WHERE ders.timeout_error_count > 0 @@ -1010,12 +1173,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SELECT check_id = 5103, /* - Rate is deadlocks-per-day, computed from DATEDIFF(SECOND, ...) rather than - DATEDIFF(DAY, ...). The DAY-based version rounded sub-day uptime to 0 and - the NULLIF then collapsed the whole expression to NULL, which evaluated as - UNKNOWN in the WHERE below and silently skipped the deadlock check for the - first calendar-day-boundary of server uptime. SECOND-based rate keeps the - threshold semantics identical for any uptime ≥ 1 second. + Rate is deadlocks-per-day, and it has to be computed from + DATEDIFF(SECOND, ...) scaled by 86400, not DATEDIFF(DAY, ...). + A DAY-based denominator rounds sub-day uptime to 0, the NULLIF then + collapses the whole expression to NULL, and NULL evaluates as UNKNOWN + in the WHERE below, which silently skips the deadlock check entirely + until the server has been up across a calendar day boundary. The + SECOND-based rate is identical in threshold semantics for any uptime + of 1 second or more. */ priority = CASE @@ -1172,7 +1337,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. details = N'SQL Server is not using locked pages in memory (LPIM). This can lead to Windows ' + N'taking memory away from SQL Server under memory pressure, causing performance issues. ' + - N'For production SQL Servers with more than 64GB of memory, LPIM should be enabled.', + N'For production SQL Servers with 32GB of memory or more, LPIM should be enabled.', url = N'https://erikdarling.com/sp_perfcheck/#LPIM' FROM sys.dm_os_sys_info AS osi WHERE osi.sql_memory_model_desc = N'CONVENTIONAL' /* Conventional means not using LPIM */ @@ -1255,38 +1420,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. N'Resource Governor', N'Enabled'; - /* Add informational message about Resource Governor with query suggestion */ - INSERT INTO - #results - ( - check_id, - priority, - category, - finding, - details, - url - ) - SELECT - check_id = 4107, - priority = 50, /* Informational: may be intentional */ - category = N'Resource Governor', - finding = N'Resource Governor Enabled', - details = - N'Resource Governor is enabled on this instance. This affects workload resource allocation and may ' + - N'impact performance by limiting resources available to various workloads. ' + - N'For more details, run these queries to explore your configuration:' + NCHAR(13) + NCHAR(10) + - N'/* Resource Governor configuration */' + NCHAR(13) + NCHAR(10) + - N'SELECT c.* FROM sys.resource_governor_configuration AS c;' + NCHAR(13) + NCHAR(10) + - N'/* Resource pools and their settings */' + NCHAR(13) + NCHAR(10) + - N'SELECT p.* FROM sys.dm_resource_governor_resource_pools AS p;' + NCHAR(13) + NCHAR(10) + - N'/* Workload groups and their settings */' + NCHAR(13) + NCHAR(10) + - N'SELECT wg.* FROM sys.dm_resource_governor_workload_groups AS wg;' + NCHAR(13) + NCHAR(10) + - N'/* Classifier function (if configured) */' + NCHAR(13) + NCHAR(10) + - N'SELECT cf.* FROM sys.resource_governor_configuration AS gc' + NCHAR(13) + NCHAR(10) + - N'CROSS APPLY (SELECT OBJECT_NAME(gc.classifier_function_id) AS classifier_function_name) AS cf;', - url = N'https://erikdarling.com/sp_perfcheck/#ResourceGovernor' - FROM sys.resource_governor_configuration AS rgc - WHERE rgc.is_enabled = 1; END ELSE BEGIN @@ -1339,7 +1472,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. VALUES ( 9998, - 90, /* Low priority informational */ + 50, /* Informational: collection error */ N'Errors', N'Error Capturing Trace Flags', N'Unable to capture trace flags: ' + ERROR_MESSAGE() @@ -1376,6 +1509,60 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. N'' ); END; + + /* + Flag notable global trace flags with interpretation. The complete list + is in #server_info; here we call out the ones that change behavior + server-wide or are redundant on modern versions, with severity by how + much they matter. Benign flags (backup-message suppression, lightweight + profiling, deadlock logging) are left in the server-info list only. + */ + INSERT INTO + #results + ( + check_id, + priority, + category, + finding, + object_name, + details, + url + ) + SELECT + check_id = 1012, + priority = + CASE tf.trace_flag + WHEN 1211 THEN 20 /* High: disables lock escalation entirely */ + WHEN 3608 THEN 20 /* High: startup-only flag set globally */ + WHEN 3609 THEN 20 /* High: startup-only flag set globally */ + WHEN 1224 THEN 30 /* Medium */ + WHEN 834 THEN 30 /* Medium */ + ELSE 40 /* Low: notable but not dangerous */ + END, + category = N'Server Configuration', + finding = N'Notable Global Trace Flag', + object_name = N'TF ' + CONVERT(nvarchar(10), tf.trace_flag), + details = + N'Global trace flag ' + + CONVERT(nvarchar(10), tf.trace_flag) + + N' is enabled. ' + + CASE tf.trace_flag + WHEN 1211 THEN N'Disables lock escalation entirely, which can bloat lock memory and hurt concurrency. Almost never recommended; prefer 1224 if you truly must.' + WHEN 1224 THEN N'Disables count-based lock escalation (escalation still happens under memory pressure). Rarely necessary.' + WHEN 3608 THEN N'A startup-only flag (recover master only) that should not be set on a running production server.' + WHEN 3609 THEN N'A startup-only flag (skip tempdb creation) that should not be set on a running production server.' + WHEN 834 THEN N'Uses large-page allocations for the buffer pool. Can slow or block startup and interacts badly with columnstore; use deliberately.' + WHEN 4199 THEN N'Enables all query optimizer hotfixes globally, which can change plans server-wide. On 2016+ prefer the database-scoped QUERY_OPTIMIZER_HOTFIXES option.' + WHEN 8048 THEN N'Partitions memory objects per CPU to reduce spinlock contention. Only relevant on high-core NUMA servers.' + WHEN 1117 THEN N'Grows all files in a filegroup together. Redundant for tempdb on 2016+, where this is already the default.' + WHEN 1118 THEN N'Forces full-extent allocation. Redundant on 2016+, where this is already the default for tempdb.' + WHEN 2371 THEN N'Lowers the auto-update-statistics threshold. Redundant on 2016+ at compatibility level 130+.' + ELSE N'Review whether it is still needed.' + END, + url = N'https://erikdarling.com/sp_perfcheck/#TraceFlags' + FROM #trace_flags AS tf + WHERE tf.global = 1 + AND tf.trace_flag IN (1211, 1224, 3608, 3609, 834, 4199, 8048, 1117, 1118, 2371); END; /* Memory information - works on all platforms */ @@ -1482,70 +1669,105 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. (116, N'DBCC Event', N'Database'), (137, N'Server Memory Change', N'Server'); - /* Get relevant events from default trace */ - INSERT INTO - #trace_events - ( - event_time, - event_class, - event_subclass, - database_name, - database_id, - file_name, - object_name, - object_type, - duration_ms, - severity, - success, - error, - text_data, - file_growth, - is_auto, - spid - ) - SELECT - event_time = t.StartTime, - event_class = t.EventClass, - event_subclass = t.EventSubClass, - database_name = DB_NAME(t.DatabaseID), - database_id = t.DatabaseID, - file_name = t.FileName, - object_name = t.ObjectName, - object_type = t.ObjectType, - duration_ms = t.Duration / 1000, /* Duration is in microseconds, convert to ms */ - severity = t.Severity, - success = t.Success, - error = t.Error, - text_data = t.TextData, - file_growth = t.IntegerData, /* Size of growth in Data/Log Auto Grow event */ - is_auto = t.IsSystem, - spid = t.SPID - FROM sys.fn_trace_gettable(@trace_path, DEFAULT) AS t - WHERE - ( - /* Auto-grow and auto-shrink events */ - t.EventClass IN (92, 93, 94, 95) - /* DBCC Events */ - OR + /* + Get relevant events from the default trace. On Linux SQL Server + sys.traces still lists a default trace, but fn_trace_gettable cannot + read it there and raises Msg 19049; the same guard also covers a + missing or unreadable trace file. On failure, leave #trace_events + empty - the trace-based checks below then simply find nothing - and + record one informational note, rather than letting the error abort + the whole procedure before it returns any findings. + */ + BEGIN TRY + INSERT INTO + #trace_events ( - t.EventClass = 116 - AND - ( - t.TextData LIKE N'%FREEPROCCACHE%' - OR t.TextData LIKE N'%FREESYSTEMCACHE%' - OR t.TextData LIKE N'%DROPCLEANBUFFERS%' - OR t.TextData LIKE N'%SHRINKDATABASE%' - OR t.TextData LIKE N'%SHRINKFILE%' - OR t.TextData LIKE N'%WRITEPAGE%' - ) + event_time, + event_class, + event_subclass, + database_name, + database_id, + file_name, + object_name, + object_type, + duration_ms, + severity, + success, + error, + text_data, + file_growth, + is_auto, + spid ) - /* Server memory change events */ - OR t.EventClass = 137 - /* Deadlock events - typically not in default trace but including for completeness */ - OR t.EventClass = 148 - ) - /* Look back at the past 7 days of events at most */ - AND t.StartTime > DATEADD(DAY, -7, SYSDATETIME()); + SELECT + event_time = t.StartTime, + event_class = t.EventClass, + event_subclass = t.EventSubClass, + database_name = DB_NAME(t.DatabaseID), + database_id = t.DatabaseID, + file_name = t.FileName, + object_name = t.ObjectName, + object_type = t.ObjectType, + duration_ms = t.Duration / 1000, /* Duration is in microseconds, convert to ms */ + severity = t.Severity, + success = t.Success, + error = t.Error, + text_data = t.TextData, + file_growth = t.IntegerData, /* Size of growth in Data/Log Auto Grow event */ + is_auto = t.IsSystem, + spid = t.SPID + FROM sys.fn_trace_gettable(@trace_path, DEFAULT) AS t + WHERE + ( + /* Auto-grow and auto-shrink events */ + t.EventClass IN (92, 93, 94, 95) + /* DBCC Events */ + OR + ( + t.EventClass = 116 + AND + ( + t.TextData LIKE N'%FREEPROCCACHE%' + OR t.TextData LIKE N'%FREESYSTEMCACHE%' + OR t.TextData LIKE N'%DROPCLEANBUFFERS%' + OR t.TextData LIKE N'%SHRINKDATABASE%' + OR t.TextData LIKE N'%SHRINKFILE%' + OR t.TextData LIKE N'%WRITEPAGE%' + ) + ) + /* Server memory change events */ + OR t.EventClass = 137 + /* Deadlock events - typically not in default trace but including for completeness */ + OR t.EventClass = 148 + ) + /* Look back at the past 7 days of events at most */ + AND t.StartTime > DATEADD(DAY, -7, SYSDATETIME()); + END TRY + BEGIN CATCH + INSERT INTO + #results + ( + check_id, + priority, + category, + finding, + details, + url + ) + VALUES + ( + 5004, + 50, /* Informational: platform limitation, not a problem */ + N'Default Trace', + N'Default Trace Not Readable', + N'The default trace is registered but could not be read (' + + ERROR_MESSAGE() + + N'). Its auto-grow, auto-shrink, and DBCC findings are ' + + N'unavailable. This is expected on Linux SQL Server, where ' + + N'the default trace cannot be read via fn_trace_gettable.', + N'https://erikdarling.com/sp_perfcheck/' + ); + END CATCH; /* Update event names from map */ UPDATE @@ -2006,7 +2228,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #wait_stats.wait_time_percent_of_uptime = (wait_time_ms * 100.0 / NULLIF(@uptime_ms, 0)); - /* Add only waits that represent >=10% of server uptime */ + /* + Surface the significant waits as a health-check diagnosis rather than a + flat list. Name each finding by what the wait means for the server + (Storage / Lock / Memory / TempDB / CPU / Log / Parallelism ...), put the + specific wait type and its plain-English meaning in object_name, and + calibrate severity by category: resource-pressure waits (locking, memory, + storage, tempdb, log, CPU) earn High by dominating uptime OR by long + average waits; parallelism is usually a cost threshold / MAXDOP symptom, + so it takes a very high share just to reach Medium; everything else stays + Low. SLEEP_TASK is collected for context but not surfaced as a finding. + */ INSERT INTO #results ( @@ -2014,44 +2246,89 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. priority, category, finding, + object_name, details, url ) - SELECT TOP (10) /* Limit to top 10 most significant waits */ + SELECT TOP (10) /* the ten most significant waits */ 6001, priority = CASE - WHEN ws.wait_time_percent_of_uptime > 100 - THEN 20 /* High: >100% of uptime */ - WHEN ws.wait_time_percent_of_uptime > 75 - THEN 20 /* High: >75% of uptime */ - WHEN ws.wait_time_percent_of_uptime >= 50 - THEN 30 /* Medium: >=50% of uptime */ - ELSE 40 /* Low: >=10% of uptime */ + WHEN ws.category IN (N'Locking', N'Memory', N'I/O', N'TempDB Contention', N'Transaction Log', N'CPU') + THEN + CASE + WHEN ws.wait_time_percent_of_uptime >= @wait_high_pct + OR ws.avg_wait_ms >= 1000.0 + THEN 20 /* High: dominates uptime, or each wait is very long */ + WHEN ws.wait_time_percent_of_uptime >= @wait_medium_pct + OR ws.avg_wait_ms >= 250.0 + THEN 30 /* Medium */ + ELSE 40 /* Low */ + END + WHEN ws.category = N'Parallelism' + THEN + CASE + WHEN ws.wait_time_percent_of_uptime >= 100.0 + THEN 30 /* Medium at most: usually a cost threshold / MAXDOP symptom */ + ELSE 40 /* Low */ + END + ELSE 40 /* Low: network, query execution, stats, AG, throttling, other */ END, category = N'Wait Statistics', finding = - N'High Impact Wait Type: ' + + CASE ws.category + WHEN N'I/O' THEN N'Storage-Related Waits' + WHEN N'Memory' THEN N'Memory-Related Waits' + WHEN N'Parallelism' THEN N'Parallelism Waits' + WHEN N'CPU' THEN N'CPU / Scheduling Waits' + WHEN N'TempDB Contention' THEN N'TempDB Contention Waits' + WHEN N'Locking' THEN N'Lock / Blocking Waits' + WHEN N'Transaction Log' THEN N'Transaction Log Waits' + WHEN N'Query Execution' THEN N'Query Execution Waits' + WHEN N'Network' THEN N'Network / Client Waits' + WHEN N'Availability Groups' THEN N'Availability Group Waits' + WHEN N'Azure SQL Throttling' THEN N'Azure SQL Throttling Waits' + WHEN N'Index Management' THEN N'Index Maintenance Waits' + WHEN N'Statistics' THEN N'Statistics Update Waits' + ELSE N'Other Significant Waits' + END, + object_name = ws.wait_type + N' (' + - ws.category + + ws.description + N')', details = - N'Wait type: ' + + N'Wait type ' + ws.wait_type + - N' represents ' + + N' accounts for ' + CONVERT(nvarchar(10), CONVERT(decimal(10, 2), ws.wait_time_percent_of_uptime)) + N'% of server uptime (' + - CONVERT(nvarchar(20), CONVERT(decimal(10, 2), ws.wait_time_minutes)) + - N' minutes). ' + - N'Average wait: ' + + CONVERT(nvarchar(20), CONVERT(decimal(10, 2), ws.wait_time_hours)) + + N' hours), averaging ' + CONVERT(nvarchar(10), CONVERT(decimal(10, 2), ws.avg_wait_ms)) + N' ms per wait. ' + - N'Description: ' + - ws.description, + CASE ws.category + WHEN N'Locking' + THEN N'Time lost to blocking. Investigate long-running transactions and blocking chains; sp_PressureDetector shows live blockers.' + WHEN N'Memory' + THEN N'Queries are waiting on memory. Review max server memory and query memory grants; oversized grants force RESOURCE_SEMAPHORE waits.' + WHEN N'I/O' + THEN N'Reads are waiting on storage. Check the per-file latency findings and whether the working set fits in the buffer pool.' + WHEN N'TempDB Contention' + THEN N'Allocation-page contention in tempdb. Use equal-sized tempdb data files, and memory-optimized tempdb metadata on 2019+.' + WHEN N'Transaction Log' + THEN N'Commits are waiting on the log. Check log storage latency, transaction sizes, and log backup frequency.' + WHEN N'CPU' + THEN N'Scheduling pressure. Review parallelism settings and plan quality; THREADPOOL specifically means worker-thread exhaustion.' + WHEN N'Parallelism' + THEN N'Usually a cost threshold for parallelism / MAXDOP tuning issue rather than a problem in itself.' + WHEN N'Network' + THEN N'Usually client-side: the application consuming results slowly or a slow network, not the server.' + ELSE N'Description: ' + ws.description + N'.' + END, url = N'https://erikdarling.com/sp_perfcheck/#WaitStats' FROM #wait_stats AS ws - WHERE ws.wait_time_percent_of_uptime >= 10.0 /* Only include waits that are at least 10% of uptime */ + WHERE ws.wait_time_percent_of_uptime >= @significant_wait_threshold_pct AND ws.wait_type <> N'SLEEP_TASK' ORDER BY ws.wait_time_percent_of_uptime DESC; @@ -2063,13 +2340,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. IF @has_view_server_state = 1 BEGIN /* Calculate pagelatch wait time for TempDB contention check. - Split into two scalar SELECTs — the previous version mixed an - aggregated value (@pagelatch_wait_hours) with a non-aggregated - one (@server_uptime_hours) in the same SELECT by joining - wait_stats to sys_info and GROUP BY'ing on the uptime - expression. It worked only because sys_info is always a - single-row view, and the GROUP BY on a scalar expression - reads oddly. */ + Deliberately two scalar SELECTs. Assigning the aggregated value + (@pagelatch_wait_hours) and the non-aggregated one + (@server_uptime_hours) in a single SELECT would mean joining + wait_stats to sys_info and grouping by the uptime expression, + which only works because sys_info is always a single-row view + and reads as though the GROUP BY were meaningful. Keep them + separate. */ SELECT @server_uptime_hours = DATEDIFF(SECOND, osi.sqlserver_start_time, SYSDATETIME()) / 3600.0 @@ -2408,6 +2685,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. priority, category, finding, + object_name, details, url ) @@ -2415,9 +2693,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. check_id = 6003, priority = 50, /* Informational: memory context */ category = N'Memory Usage', - finding = - N'Top Memory Consumer: ' + - domc.type, + finding = N'Top Memory Consumer', + object_name = domc.type, details = N'Memory clerk "' + domc.type + @@ -2625,7 +2902,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ORDER BY i.avg_io_stall_ms DESC; - /* Check 6201 (High Database I/O Stalls) removed — duplicated by file-level checks 3001/3002/3003 */ + /* Check 6201 (High Database I/O Stalls) removed: duplicated by file-level checks 3001/3002/3003 */ END; /* @@ -2777,15 +3054,34 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. check_id = 3001, priority = CASE - WHEN i.avg_read_latency_ms > @slow_read_ms * 2 - THEN 20 /* High: >1000ms is severe */ - ELSE 30 /* Medium: >500ms is significant */ + WHEN i.avg_read_latency_ms > @slow_read_ms * 5 + THEN 20 /* High: >100ms average reads (5x threshold) is bad storage */ + ELSE 30 /* Medium: over the 20ms threshold, worth investigating */ END, category = N'Storage Performance', finding = N'Slow Read Latency', database_name = i.database_name, object_name = - i.file_name + + /* + Drive/volume plus the physical file name on disk. This makes a file + finding self-contained, tells apart every file (logical names repeat + across databases and don't say where the file lives), and lines up + with the drive-level rollup (check_id 3003), which shows the same + drive. For blob storage drive_location already holds the whole URL, so + use it as-is; otherwise take the last path segment after the final + separator. + */ + CASE + WHEN i.drive_location = i.physical_name + THEN i.physical_name + ELSE + ISNULL(i.drive_location + N' ', N'') + + RIGHT + ( + i.physical_name, + CHARINDEX(N'\', REVERSE(i.physical_name) + N'\') - 1 + ) + END + N' (' + i.type_desc + N')', @@ -2820,15 +3116,34 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. check_id = 3002, priority = CASE - WHEN i.avg_write_latency_ms > @slow_write_ms * 2 - THEN 20 /* High: >1000ms is severe */ - ELSE 30 /* Medium: >500ms is significant */ + WHEN i.avg_write_latency_ms > @slow_write_ms * 5 + THEN 20 /* High: >100ms average writes (5x threshold) is bad storage */ + ELSE 30 /* Medium: over the 20ms threshold, worth investigating */ END, category = N'Storage Performance', finding = N'Slow Write Latency', database_name = i.database_name, object_name = - i.file_name + + /* + Drive/volume plus the physical file name on disk. This makes a file + finding self-contained, tells apart every file (logical names repeat + across databases and don't say where the file lives), and lines up + with the drive-level rollup (check_id 3003), which shows the same + drive. For blob storage drive_location already holds the whole URL, so + use it as-is; otherwise take the last path segment after the final + separator. + */ + CASE + WHEN i.drive_location = i.physical_name + THEN i.physical_name + ELSE + ISNULL(i.drive_location + N' ', N'') + + RIGHT + ( + i.physical_name, + CHARINDEX(N'\', REVERSE(i.physical_name) + N'\') - 1 + ) + END + N' (' + i.type_desc + N')', @@ -2854,6 +3169,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. priority, category, finding, + object_name, details, url ) @@ -2861,9 +3177,12 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. check_id = 3003, priority = 20, /* High: systemic storage problem */ category = N'Storage Performance', - finding = - N'Multiple Slow Files on Storage Location ' + - i.drive_location, + /* + Stable label; the drive/volume that varies per row goes in object_name so + this finding groups and the identity lives in a column, not the sentence. + */ + finding = N'Multiple Slow Files on Storage Location', + object_name = i.drive_location, details = N'Storage location ' + i.drive_location + @@ -3057,82 +3376,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ IF @azure_sql_db = 0 /* Skip these checks for Azure SQL DB */ BEGIN - /* Check for non-default configuration values */ - INSERT INTO - #results - ( - check_id, - priority, - category, - finding, - details, - url - ) - SELECT - check_id = 1000, - priority = 50, /* Informational: non-default config */ - category = N'Server Configuration', - finding = N'Non-Default Configuration: ' + c.name, - details = - N'Configuration option "' + c.name + - N'" has been changed from the default. Current: ' + - CONVERT(nvarchar(50), c.value_in_use) + - CASE - /* Configuration options from your lists */ - WHEN c.name = N'access check cache bucket count' THEN N', Default: 0' - WHEN c.name = N'access check cache quota' THEN N', Default: 0' - WHEN c.name = N'Ad Hoc Distributed Queries' THEN N', Default: 0' - WHEN c.name = N'ADR cleaner retry timeout (min)' THEN N', Default: 120' - WHEN c.name = N'ADR Cleaner Thread Count' THEN N', Default: 1' - WHEN c.name = N'ADR Preallocation Factor' THEN N', Default: 4' - WHEN c.name = N'affinity mask' THEN N', Default: 0' - WHEN c.name = N'affinity I/O mask' THEN N', Default: 0' - WHEN c.name = N'affinity64 mask' THEN N', Default: 0' - WHEN c.name = N'affinity64 I/O mask' THEN N', Default: 0' - WHEN c.name = N'cost threshold for parallelism' THEN N', Default: 5' - WHEN c.name = N'max degree of parallelism' THEN N', Default: 0' - WHEN c.name = N'max server memory (MB)' THEN N', Default: 2147483647' - WHEN c.name = N'max worker threads' THEN N', Default: 0' - WHEN c.name = N'min memory per query (KB)' THEN N', Default: 1024' - WHEN c.name = N'min server memory (MB)' THEN N', Default: 0' - WHEN c.name = N'optimize for ad hoc workloads' THEN N', Default: 0' - WHEN c.name = N'priority boost' THEN N', Default: 0' - WHEN c.name = N'query governor cost limit' THEN N', Default: 0' - WHEN c.name = N'recovery interval (min)' THEN N', Default: 0' - WHEN c.name = N'tempdb metadata memory-optimized' THEN N', Default: 0' - WHEN c.name = N'lightweight pooling' THEN N', Default: 0' - ELSE N', Default: Unknown' - END, - url = N'https://erikdarling.com/sp_perfcheck/#ServerSettings' - FROM sys.configurations AS c - WHERE - /* Access check cache settings */ - (c.name = N'access check cache bucket count' AND c.value_in_use <> 0) - OR (c.name = N'access check cache quota' AND c.value_in_use <> 0) - OR (c.name = N'Ad Hoc Distributed Queries' AND c.value_in_use <> 0) - /* ADR settings */ - OR (c.name = N'ADR cleaner retry timeout (min)' AND c.value_in_use NOT IN (0, 15, 120)) - OR (c.name = N'ADR Cleaner Thread Count' AND c.value_in_use <> 1) - OR (c.name = N'ADR Preallocation Factor' AND c.value_in_use NOT IN (0, 4)) - /* Affinity settings */ - OR (c.name = N'affinity mask' AND c.value_in_use <> 0) - OR (c.name = N'affinity I/O mask' AND c.value_in_use <> 0) - OR (c.name = N'affinity64 mask' AND c.value_in_use <> 0) - OR (c.name = N'affinity64 I/O mask' AND c.value_in_use <> 0) - /* Common performance settings */ - OR (c.name = N'cost threshold for parallelism' AND c.value_in_use <> 5) - OR (c.name = N'max degree of parallelism' AND c.value_in_use <> 0) - OR (c.name = N'max server memory (MB)' AND c.value_in_use <> 2147483647) - OR (c.name = N'max worker threads' AND c.value_in_use <> 0) - OR (c.name = N'min memory per query (KB)' AND c.value_in_use <> 1024) - OR (c.name = N'min server memory (MB)' AND c.value_in_use NOT IN (0, 16)) - OR (c.name = N'optimize for ad hoc workloads' AND c.value_in_use <> 0) - OR (c.name = N'priority boost' AND c.value_in_use <> 0) - OR (c.name = N'query governor cost limit' AND c.value_in_use <> 0) - OR (c.name = N'recovery interval (min)' AND c.value_in_use <> 0) - OR (c.name = N'tempdb metadata memory-optimized' AND c.value_in_use <> 0) - OR (c.name = N'lightweight pooling' AND c.value_in_use <> 0); - /* TempDB Configuration Checks (not applicable to Azure SQL DB) */ @@ -3540,7 +3783,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. VALUES ( 1002, - 20, /* High priority — OS-starvation risk */ + 20, /* High priority: OS-starvation risk */ N'Server Configuration', N'Max Server Memory Too Close To Physical Memory', N'Max server memory (' + @@ -3579,8 +3822,14 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ); END; - /* Cost Threshold for Parallelism check */ - IF @cost_threshold <= 5 + /* + Cost threshold for parallelism set too low. The default of 5 is far too + low for modern hardware: it shoves trivial queries into parallel plans, + burning CPU and worker threads on work that runs faster single-threaded. + Flag anything under 50 (a sane starting point), escalating when it is + still at or near the terrible default. + */ + IF @cost_threshold < 50 BEGIN INSERT INTO #results @@ -3595,12 +3844,18 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. VALUES ( 1004, - 40, /* Low: config recommendation */ + CASE + WHEN @cost_threshold <= 5 + THEN 20 /* High: still at or near the default of 5 */ + ELSE 40 /* Low: configured, but lower than recommended */ + END, N'Server Configuration', - N'Low Cost Threshold for Parallelism', + N'Cost Threshold for Parallelism Too Low', N'Cost threshold for parallelism is set to ' + CONVERT(nvarchar(10), @cost_threshold) + - N'. Low values can cause excessive parallelism for small queries.', + N'. Low values push trivial queries into parallel plans, wasting CPU ' + + N'and worker threads on work that runs faster single-threaded. A ' + + N'starting point of 50 is far more reasonable than the default of 5.', N'https://erikdarling.com/sp_perfcheck/#CostThreshold' ); END; @@ -4238,6 +4493,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. category, finding, database_name, + object_name, details, url ) @@ -4245,10 +4501,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. check_id = 7003, priority = 20, /* High: apps can't connect */ category = N'Database Configuration', - finding = - N'Restricted Access Mode: ' + - d.user_access_desc, + finding = N'Restricted Access Mode', database_name = d.name, + object_name = d.user_access_desc, details = N'Database is not in MULTI_USER mode. Current mode: ' + d.user_access_desc + @@ -4306,51 +4561,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. OR d.is_auto_update_stats_on = 0 ); - /* Check ANSI settings that might cause issues */ - INSERT INTO - #results - ( - check_id, - priority, - category, - finding, - database_name, - details, - url - ) - SELECT - check_id = 7005, - priority = 50, /* Informational */ - category = N'Database Configuration', - finding = N'ANSI Settings Require Review', - database_name = d.name, - details = - N'One or more ANSI settings differ from recommended best practices: ' + - CASE WHEN d.is_ansi_null_default_on = 0 THEN N'ANSI_NULL_DEFAULT OFF (recommended ON), ' ELSE N'' END + - CASE WHEN d.is_ansi_nulls_on = 0 THEN N'ANSI_NULLS OFF (recommended ON), ' ELSE N'' END + - CASE WHEN d.is_ansi_padding_on = 0 THEN N'ANSI_PADDING OFF (recommended ON), ' ELSE N'' END + - CASE WHEN d.is_ansi_warnings_on = 0 THEN N'ANSI_WARNINGS OFF (recommended ON), ' ELSE N'' END + - CASE WHEN d.is_arithabort_on = 0 THEN N'ARITHABORT OFF (recommended ON in many contexts), ' ELSE N'' END + - CASE WHEN d.is_concat_null_yields_null_on = 0 THEN N'CONCAT_NULL_YIELDS_NULL OFF (recommended ON), ' ELSE N'' END + - CASE WHEN d.is_numeric_roundabort_on = 1 THEN N'NUMERIC_ROUNDABORT ON (recommended OFF), ' ELSE N'' END + - CASE WHEN d.is_quoted_identifier_on = 0 THEN N'QUOTED_IDENTIFIER OFF (recommended ON), ' ELSE N'' END + - N'These settings may lead to inconsistent behavior, reduced feature compatibility, or unexpected query results ' + - N'if they do not align with recommended best practices.', - url = N'https://erikdarling.com/sp_perfcheck/#ANSISettings' - FROM #databases AS d - WHERE d.database_id = @current_database_id - AND - ( - d.is_ansi_null_default_on = 0 - OR d.is_ansi_nulls_on = 0 - OR d.is_ansi_padding_on = 0 - OR d.is_ansi_warnings_on = 0 - OR d.is_arithabort_on = 0 - OR d.is_concat_null_yields_null_on = 0 - OR d.is_numeric_roundabort_on = 1 - OR d.is_quoted_identifier_on = 0 - ); - /* Check Query Store Status */ INSERT INTO #results @@ -4441,6 +4651,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHEN 0 THEN N''No specific reason identified.'' WHEN 2 THEN N''Database is in single user mode.'' WHEN 4 THEN N''Database is in emergency mode.'' + WHEN 1 THEN N''Database is in read-only mode.'' WHEN 8 THEN N''Database is an Availability Group secondary.'' WHEN 65536 THEN N''Query Store has reached maximum size: '' + CONVERT(nvarchar(20), qso.current_storage_size_mb) + @@ -4457,7 +4668,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE qso.desired_state <> 0 /* Not intentionally OFF */ AND qso.readonly_reason <> 8 /* Ignore AG secondaries */ AND qso.desired_state <> qso.actual_state /* States don''t match */ - AND qso.actual_state IN (0, 3); /* Either OFF or READ_ONLY when it shouldn''t be */'; + /* + OFF(0), READ_ONLY(1), or ERROR(3) while the desired state is + READ_WRITE. State 1 has to be in this list: a Query Store that + auto-flips to READ_ONLY because it filled up (readonly_reason + 65536) is its most common failure mode, and omitting 1 means that + case is never flagged. AG secondaries (readonly_reason 8) and + intentional READ_ONLY (desired = actual) are already excluded by + the predicates above. + */ + AND qso.actual_state IN (0, 1, 3);'; IF @debug = 1 BEGIN @@ -4595,11 +4815,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. (@current_database_id, @current_database_name, 35, N''OPTIMIZED_PLAN_FORCING'', NULL, NULL, 1), (@current_database_id, @current_database_name, 37, N''DOP_FEEDBACK'', NULL, NULL, 1), (@current_database_id, @current_database_name, 39, N''FORCE_SHOWPLAN_RUNTIME_PARAMETER_COLLECTION'', NULL, NULL, 1), - /* SQL Server 2025 options - IDs to be verified against actual SQL Server 2025 instance */ - (@current_database_id, @current_database_name, 40, N''PREVIEW_FEATURES'', NULL, NULL, 1), - (@current_database_id, @current_database_name, 41, N''OPTIMIZED_SP_EXECUTESQL'', NULL, NULL, 1), - (@current_database_id, @current_database_name, 42, N''FULLTEXT_INDEX_VERSION'', NULL, NULL, 1), - (@current_database_id, @current_database_name, 43, N''OPTIONAL_PARAMETER_OPTIMIZATION'', NULL, NULL, 1); + /* SQL Server 2025 options, real configuration_ids from the catalog (17.0). */ + (@current_database_id, @current_database_name, 42, N''OPTIMIZED_SP_EXECUTESQL'', NULL, NULL, 1), + (@current_database_id, @current_database_name, 44, N''FULLTEXT_INDEX_VERSION'', NULL, NULL, 1), + (@current_database_id, @current_database_name, 47, N''OPTIONAL_PARAMETER_OPTIMIZATION'', NULL, NULL, 1); /* Get actual non-default settings */ INSERT INTO @@ -4642,10 +4861,10 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHEN sc.name = N''MEMORY_GRANT_FEEDBACK_PERSISTENCE'' AND CONVERT(integer, sc.value) = 1 THEN 1 WHEN sc.name = N''MEMORY_GRANT_FEEDBACK_PERCENTILE_GRANT'' AND CONVERT(integer, sc.value) = 1 THEN 1 WHEN sc.name = N''OPTIMIZED_PLAN_FORCING'' AND CONVERT(integer, sc.value) = 1 THEN 1 - WHEN sc.name = N''DOP_FEEDBACK'' AND CONVERT(integer, sc.value) = 0 THEN 1 + /* DOP_FEEDBACK default flipped from 0 (2019-2022) to 1 (2025+); inject the version-appropriate default so it is not flagged non-default on every 2025 database. */ + WHEN sc.name = N''DOP_FEEDBACK'' AND CONVERT(integer, sc.value) = ' + CASE WHEN @product_version_major >= 17 THEN N'1' ELSE N'0' END + N' THEN 1 WHEN sc.name = N''FORCE_SHOWPLAN_RUNTIME_PARAMETER_COLLECTION'' AND CONVERT(integer, sc.value) = 0 THEN 1 - /* SQL Server 2025 options */ - WHEN sc.name = N''PREVIEW_FEATURES'' AND CONVERT(integer, sc.value) = 0 THEN 1 + /* SQL Server 2025 options. PREVIEW_FEATURES was dropped: its real default is 1, not 0, and it is not evaluated here. These three defaults match the live 2025 catalog (0, 2, 1). */ WHEN sc.name = N''OPTIMIZED_SP_EXECUTESQL'' AND CONVERT(integer, sc.value) = 0 THEN 1 WHEN sc.name = N''FULLTEXT_INDEX_VERSION'' AND CONVERT(integer, sc.value) = 2 THEN 1 WHEN sc.name = N''OPTIONAL_PARAMETER_OPTIMIZATION'' AND CONVERT(integer, sc.value) = 1 THEN 1 @@ -4657,7 +4876,17 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 1, 2, 3, 4, 7, 8, 9, 10, 13, 16, 17, 18, 19, 20, 24, 27, 28, 31, 33, 34, 35, 37, 39, - 40, 41, 42, 43 /* SQL Server 2025 options */ + /* + SQL Server 2025 options, using the real + configuration_ids from the catalog. 42, 44, and 47 + are the three the CASE above actually evaluates. + 40 and 41 do not belong here: they are + READABLE_SECONDARY_TEMPORARY_STATS_AUTO_CREATE and + _UPDATE, which the CASE has no entry for, so pulling + them flags every 2025 database non-default even at + their default of 1. 43 does not exist. + */ + 42, 44, 47 ); END;'; @@ -4735,33 +4964,6 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. END; END CATCH; - /* Check for non-default target recovery time */ - INSERT INTO - #results - ( - check_id, - priority, - category, - finding, - database_name, - details, - url - ) - SELECT - check_id = 7007, - priority = 50, /* Informational */ - category = N'Database Configuration', - finding = N'Non-Default Target Recovery Time', - database_name = d.name, - details = - N'Database target recovery time is ' + - CONVERT(nvarchar(20), d.target_recovery_time_in_seconds) + - N' seconds, which differs from the default of 60 seconds. This affects checkpoint frequency and recovery time.', - url = N'https://erikdarling.com/sp_perfcheck/#RecoveryTime' - FROM #databases AS d - WHERE d.database_id = @current_database_id - AND d.target_recovery_time_in_seconds <> 60; - /* Check transaction durability */ INSERT INTO #results @@ -4771,6 +4973,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. category, finding, database_name, + object_name, details, url ) @@ -4778,8 +4981,9 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. check_id = 7008, priority = 30, /* Medium: data loss risk on crash */ category = N'Database Configuration', - finding = N'Delayed Durability: ' + d.delayed_durability_desc, + finding = N'Delayed Durability', database_name = d.name, + object_name = d.delayed_durability_desc, details = N'Database uses ' + d.delayed_durability_desc + @@ -4789,36 +4993,51 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WHERE d.database_id = @current_database_id AND d.delayed_durability_desc <> N'DISABLED'; - /* Check if the database has accelerated database recovery disabled with SI/RCSI enabled */ - INSERT INTO - #results - ( - check_id, - priority, - category, - finding, - database_name, - details, - url - ) - SELECT - check_id = 7009, - priority = 40, /* Low: recommendation */ - category = N'Database Configuration', - finding = N'Accelerated Database Recovery Not Enabled With Snapshot Isolation', - database_name = d.name, - details = - N'Database has Snapshot Isolation or RCSI enabled but Accelerated Database Recovery (ADR) is disabled. ' + - N'ADR can significantly improve performance with these isolation levels by reducing version store cleanup overhead.', - url = N'https://erikdarling.com/sp_perfcheck/#ADR' - FROM #databases AS d - WHERE d.database_id = @current_database_id - AND d.is_accelerated_database_recovery_on = 0 - AND - ( - d.snapshot_isolation_state_desc = N'ON' - OR d.is_read_committed_snapshot_on = 1 - ); + /* + Check if the database has accelerated database recovery disabled + with SI/RCSI enabled. + + Gated on the column actually existing. Accelerated Database + Recovery arrived in SQL Server 2019; on 2016 and 2017 the + is_accelerated_database_recovery_on column does not exist, so the + #databases builder hardcodes it to 0 for every database. The gate is + what keeps the "= 0" predicate below from being trivially true on + those versions, where the check would recommend enabling a feature + they do not have for every database running snapshot isolation or + RCSI. + */ + IF @has_is_accelerated_database_recovery = 1 + BEGIN + INSERT INTO + #results + ( + check_id, + priority, + category, + finding, + database_name, + details, + url + ) + SELECT + check_id = 7009, + priority = 40, /* Low: recommendation */ + category = N'Database Configuration', + finding = N'Accelerated Database Recovery Not Enabled With Snapshot Isolation', + database_name = d.name, + details = + N'Database has Snapshot Isolation or RCSI enabled but Accelerated Database Recovery (ADR) is disabled. ' + + N'ADR can significantly improve performance with these isolation levels by reducing version store cleanup overhead.', + url = N'https://erikdarling.com/sp_perfcheck/#ADR' + FROM #databases AS d + WHERE d.database_id = @current_database_id + AND d.is_accelerated_database_recovery_on = 0 + AND + ( + d.snapshot_isolation_state_desc = N'ON' + OR d.is_read_committed_snapshot_on = 1 + ); + END; /* Check if ledger is enabled */ INSERT INTO @@ -5115,11 +5334,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. r.check_id; END TRY BEGIN CATCH - IF @@TRANCOUNT > 0 - BEGIN - ROLLBACK; - END; - + /* + No ROLLBACK. This procedure only reads diagnostics and builds a result + set - it opens no transaction of its own, so a ROLLBACK here could only + unwind the CALLER's transaction on an internal error, destroying work it + had no part in. That is the standard error-handling template misapplied + to a read-only procedure. + */ THROW; END CATCH; END; diff --git a/sp_PerfCheck/tests/README.md b/sp_PerfCheck/tests/README.md new file mode 100644 index 00000000..09091672 --- /dev/null +++ b/sp_PerfCheck/tests/README.md @@ -0,0 +1,209 @@ +# sp_PerfCheck Tests + +`sp_PerfCheck` is a read-only server-health diagnostic. It inspects DMVs, +`sys.configurations`, and server state and returns two result sets: + +1. `#server_info` -- columns `[Server Information]`, `[Details]` +2. `#results` -- columns `check_id`, `priority`, `priority_label`, `category`, + `finding`, `database_name`, `object_name`, `details`, `url` + +**Run the automated suite before and after any change to `sp_PerfCheck.sql`.** +Compiling proves nothing about behavior. This suite catches crashes, output-shape +regressions, and regressions in the checks whose firing conditions can be created +deterministically. + +## The hard problem: determinism on a shared instance + +Almost every `sp_PerfCheck` finding depends on **volatile server state** you +cannot control on a shared test box -- offline schedulers, read/write latency, +memory-starved queries, deadlock counts, memory dumps, accumulated wait +statistics. Asserting on any of those produces a suite that flakes, and a suite +that flakes is worse than no suite. So this harness asserts **only** on things +that are controllable or structurally invariant, and it is honest (below) about +the large surface it therefore does not cover. + +## Automated + +| Script | What it does | +| --- | --- | +| `run_tests.py` | Drives `dbo.sp_PerfCheck` over `sqlcmd`, parses the two result sets, and asserts **39** expectations across four groups: 11 structural/smoke, 12 forced server-configuration, 11 forced database-configuration, 5 inaccessible-database regression. Self-cleaning and idempotent. | + +``` +cd sp_PerfCheck/tests +python run_tests.py --server SQL2022 +``` + +`--server` and `--password` default to `SQL2022` / the standard local `sa` +password. Expect `39 passed, 0 failed`. The proc must be installed in `master` +on the target instance (there is a preflight that fails fast if it is not). + +### What it covers + +**1. Structural / smoke (11).** These catch crashes and output-shape regressions +across versions, and are always valid regardless of instance state: + +- default run raises no severe SQL error (severity 16+); +- default run populates `#server_info` (the always-present `Run Date` row); +- default run emits the `#results` set with all nine expected column names; +- every `#results` row is well formed -- carries a `check_id`, `priority`, + `category`, and `finding`; +- `@help = 1` returns help text and short-circuits (no findings, no + `#server_info`); +- `@debug = 1` runs clean, prints diagnostics, and still completes. + +**2. Forced server-configuration (12).** The "Cost Threshold for Parallelism Too +Low" check (`check_id 1004`) reads `sys.configurations`, and cost threshold for +parallelism is an advanced option that needs no restart, so it is safe to flip on +a test instance. The harness drives it to three values and proves both the +finding **and its severity escalation**: + +| `cost threshold for parallelism` | Expected | +| --- | --- | +| 55 (sane, >= 50) | finding **absent** | +| 5 (the bad default) | finding **present** at High (priority 20) | +| 25 (low, but not the default) | finding **present** at Low (priority 40) | + +The presence assertions also check the row is well formed (`check_id 1004`, +`category = Server Configuration`) and that `details` names the configured value. +The **absence** assertion is paired with a **positive control** -- `#server_info` +is populated on the same run -- so an empty or failed result set cannot pass +vacuously; the matching presence assertions are the proof that the check itself +actually runs. The three-value design is what makes the graduated priority +testable: a harness that only proved "fires below 50" would not notice the High +and Low bands collapsing into one. + +The option's **original `value_in_use` is captured first and restored precisely** +in a `finally` block that runs even on assertion failure, and the harness dumps +the entire `sys.configurations` table before and after and asserts **zero net +change**. `show advanced options` is set while configuring and restored the same +way. The suite is idempotent: run it twice, same result, no leaked config. + +Before baselining, the harness issues a bare `RECONFIGURE` to flush any +pre-existing pending config change. Some images ship one (the SQL Server 2017 CI +container has `clr strict security` configured on but not yet in use), and +without the flush the harness's own `RECONFIGURE` calls would apply that staged +change mid-run and it would surface as a net change the harness never made. + +**3. Forced database-configuration (11).** Two database-level checks read pure, +instantly-reversible metadata, so a **throwaway scratch database** +(`perfcheck_test_scratch`, created and dropped by the harness) can force each on +and off: + +| Check | check_id | Forced via | +| --- | --- | --- | +| Auto-Shrink Enabled | 7001 | `ALTER DATABASE ... SET AUTO_SHRINK ON/OFF` | +| Auto Update Statistics Disabled | 7004 | `ALTER DATABASE ... SET AUTO_UPDATE_STATISTICS OFF/ON` | + +The two toggles control each other's positive control: in each of two runs one +finding is present (scoped to the scratch database **by name**, which proves the +database was actually analyzed) while the other is absent (a real absence, not a +skipped database). Across the two runs each toggle is proven both present and +absent. The scratch database is dropped in a `finally` block, and setup is +idempotent (it drops any leaked copy first). + +**4. Inaccessible-database regression (5).** A separate scratch database +(`perfcheck_test_inaccessible`) is closed with `AUTO_CLOSE` and then taken +`OFFLINE`, and the harness asserts that a scoped run against each state, plus a +**whole-instance run with the offline database present**, all complete without a +severe error. See the section below for what this is guarding. + +### Why the forced-condition assertions are trusted + +Every forced-condition assertion was watched fail-to-fire at the non-triggering +state and fire at the triggering state before being trusted. The pairing is what +makes it non-vacuous: if detection were broken so the finding never appeared, the +**present** assertion would fail; if it always appeared, the **absent** assertion +would fail. Both passing means detection is correct and bidirectional. A harness +built with `forced == default` (so the "present" run could never fire) was +confirmed to go red, proving the assertions are not rubber-stamps. + +## What is NOT covered, and why + +This is the honest part, and it is a required part. The following checks read +**volatile or environment-bound state that cannot be created and reset +deterministically** on a shared instance, so this harness deliberately does not +assert on them. Do not read a green run as "all of `sp_PerfCheck` works" -- it +means the covered surface works. + +**Volatile runtime state (cannot be forced without destabilizing the box, and +would flake run-to-run):** + +- Offline CPU Schedulers (`4001`) +- Memory-Starved Queries: Forced Grants (`4101`) and Grant Timeouts (`4103`) -- + both depend on live resource semaphore counters +- Memory Dumps Detected In Last 90 Days (`4102`) -- depends on dump history +- High Number of Deadlocks (`5103`) -- depends on the deadlock counter +- Large Security Token Cache (`4104`) +- Slow Read Latency / Slow Write Latency (`3001`, `3002`) and the drive-level + rollup (`3003`) -- from `sys.dm_io_virtual_file_stats`; latency cannot be + dialed to a threshold on demand +- Everything in the **Wait Statistics** (`6001`), **CPU Scheduling** + (`6101`, `6102`), and **Memory Usage** (`6002`, `6003`) categories -- + cumulative since restart, uncontrollable +- The default-trace findings (`5001` slow auto-grow, `5002` auto-shrink events, + `5003` disruptive DBCC) -- these read events that already happened, and + `5000` / `5004` fire only when the trace cannot be read at all + +**Environment / OS state (would require an OS privilege change and a service +restart, which CI cannot do):** + +- Lock Pages in Memory Not Enabled (`4105`) +- Instant File Initialization Disabled (`4106`) + +**Deliberately-not-touched because forcing them is disruptive, dangerous, or +requires a restart:** + +- TempDB Configuration checks (`2001`-`2006`, `2010`) -- changing tempdb's file + layout needs a restart to take effect and destabilizes a shared instance +- Configuration Pending Reconfigure (`1007`) -- forcing it means deliberately + leaving a `RECONFIGURE`-pending dirty state on the box, which is exactly the + state the harness flushes before it starts +- Notable Global Trace Flag (`1012`) -- setting a global trace flag with + `DBCC TRACEON (..., -1)` changes behavior for every session on the instance +- `max server memory` (`1002`), affinity masks (`1008`-`1011`), `max degree of + parallelism` (`1003`), and priority boost (`1005`) -- dangerous, + destabilizing, or something a real workload may depend on + +**Additional database-level checks that ARE forceable but are not asserted +(representative coverage only):** Auto-Close Enabled (`7002`), Restricted Access +Mode (`7003`), Query Store Not Enabled (`7006`), Delayed Durability (`7008`), +Accelerated Database Recovery with SI/RCSI (`7009`), Ledger (`7010`), Query Store +health (`7011`, `7012`), non-default database scoped configurations (`7020`), and +the file auto-growth checks (`7101`-`7104`) could each be forced on a scratch +database. The harness asserts a representative pair (Auto-Shrink, Auto Update +Statistics) to exercise the per-database cursor path and the `database_name` +scoping without turning the suite into an exhaustive per-setting matrix. See the +Auto-Close note below for why that specific one was dropped. + +### A real fragility this harness surfaced, now fixed and asserted + +The database-scoped group was originally written to force **AUTO_CLOSE** as its +second toggle, and it uncovered a crash: a closed (`AUTO_CLOSE`) or `OFFLINE` +database returns `NULL` for `is_accelerated_database_recovery_on` in +`sys.databases` -- ADR state lives with the database, not master metadata, so it +cannot be read while the database is shut -- and that column was `NOT NULL` in the +procedure's `#databases` table, so the collection `INSERT` rejected the `NULL` +with `Msg 515` and aborted the whole run. Because the collection has no state +filter, a single inaccessible database could take down a full-instance run. It +showed up on SQL Server 2025 first, but the mechanism is version-independent. + +The column is now nullable, and the `Inaccessible` group asserts the fix stays in: +it closes and then offlines a scratch database and confirms scoped runs and a +whole-instance run all complete without `Msg 515`. The database-scoped toggle +group uses `AUTO_UPDATE_STATISTICS` instead of `AUTO_CLOSE` for a separate reason +-- it keeps the database open, so the finding it forces is actually assessable. + +## Running in CI + +This suite runs in GitHub Actions as part of `.github/workflows/sql-tests.yml`, +against SQL Server **2017, 2019, 2022, and 2025** containers. It reconfigures the +instance and restores it, which is acceptable against a throwaway per-job +container but is still a "run it on a box you control" tool by nature -- it always +restores every option it touches and drops every database it creates, but do not +point it at a shared instance somebody else depends on. + +CI overrides two environment variables the harness reads: `SQLCMD_BIN` (the path +to the container's `sqlcmd`) and `SQLCMD_CONN_ARGS`, set to `-C -N disable` so the +modern Go TLS stack does not reject the SQL Server 2017 container's self-signed +certificate outright. Locally both default to plain `sqlcmd` on `PATH` with no +extra connection arguments. diff --git a/sp_PerfCheck/tests/run_tests.py b/sp_PerfCheck/tests/run_tests.py new file mode 100644 index 00000000..8d1d605b --- /dev/null +++ b/sp_PerfCheck/tests/run_tests.py @@ -0,0 +1,649 @@ +""" +sp_PerfCheck assertion test harness +==================================== +sp_PerfCheck is a read-only server-health diagnostic. It inspects DMVs, +sys.configurations, and server state, and returns two result sets: + + 1. #server_info : columns [Server Information], [Details] + 2. #results : columns check_id, priority, priority_label, category, + finding, database_name, object_name, details, url + +Almost every finding depends on VOLATILE server state (offline schedulers, I/O +latency, memory-starved queries, deadlock counts, memory dumps) that cannot be +controlled on a shared instance, so this harness does NOT assert on any of them. +A harness that flakes is worse than no harness. It asserts only on things that +are controllable or structurally invariant: + + * Structural / smoke: the proc runs without error; #server_info is populated; + a default run emits a well-formed #results set (every row carries a + check_id, priority, category, and finding); @help returns help text and no + findings; @debug = 1 runs clean and prints diagnostics. + + * Forced-condition (the high-value core): conditions this harness can safely + create and reset, proven BIDIRECTIONALLY (finding absent when the condition + is not present, finding present when it is): + - Server config: the "Cost Threshold for Parallelism Too Low" check + (check_id 1004) reads sys.configurations. The harness forces CTFP to a + sane value (absent), the default of 5 (present, High), and a low-but- + not-default value (present, Low), proving both the finding and its + severity escalation. The original value_in_use is captured first and + restored precisely in a finally block that runs even on assertion + failure. The instance is never left reconfigured. + - Database config: Auto-Shrink Enabled (7001) and Auto Update Statistics + Disabled (7004) are forced on and off on a throwaway scratch database + that is created and dropped by the harness. + +Each absence assertion is paired with a positive control (#server_info +populated) so an empty or failed result set cannot pass vacuously, and with the +matching presence assertion, which proves the check actually runs. + +After all tests the full sys.configurations snapshot is compared before/after to +prove zero net configuration change. The suite is idempotent: run it twice, same +result, no leaked config. + +Usage: + python run_tests.py [--server SQL2022] [--password L!nt0044] + +Exits 1 if any assertion fails. +""" + +import argparse +import os +import re +import shlex +import subprocess +import sys + + +# The Cost Threshold for Parallelism check (check_id 1004) fires when CTFP is +# set below 50, escalating to High (priority 20) at or below the default of 5 +# and staying Low (priority 40) between 6 and 49. CTFP is an advanced option +# that needs no restart, so it is safe to flip on a test instance; the harness +# sets 'show advanced options' while configuring it and restores both. +CTFP = "cost threshold for parallelism" +CTFP_SANE = 55 # >= 50: no finding +CTFP_DEFAULT = 5 # bad, and at the default: finding at High priority (20) +CTFP_LOW = 25 # bad, below 50 but above the default: finding at Low (40) + +# Column names expected in the #results header (shape-regression guard). +RESULTS_COLUMNS = [ + "check_id", "priority", "priority_label", "category", "finding", + "database_name", "object_name", "details", "url", +] + +# A diagnostic line the procedure prints only under @debug = 1. +DEBUG_MARKER = "Collecting server information" + +# The last row the procedure always adds to #server_info (line ~5150). Its +# presence proves the proc ran to completion and emitted #server_info, which is +# the deterministic positive control for every absence assertion. +SERVER_INFO_MARKER = "Run Date" + + +def find_sql_errors(text): + """Return any SQL errors of severity 16 or higher found in text. + + go-sqlcmd reports errors on stdout, so both streams have to be checked. + Matching the severity numerically catches Level 16 through 19 rather than + only the literal "Level 16". + """ + if not text: + return [] + return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text) + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment so + one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to the + go-based 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI points SQLCMD_BIN + at its own binary and sets SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + self-signed cert and disable encryption, since the modern Go TLS stack + rejects the SQL Server 2017 container's certificate outright.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + +def _sqlcmd(server, password, sql, headers=True): + """Run a batch and return (stdout, stderr) decoded as UTF-8. + + Capturing bytes and decoding as UTF-8 keeps any non-ASCII server/db name + from being mangled by the Windows console code page (text=True would do + that). Output is tab-delimited and trimmed (-W -s TAB) and the line width is + maxed (-w 65535) so wide rows are not wrapped mid-row. + """ + cmd = _sqlcmd_prefix() + [ + "-S", server, "-U", "sa", "-P", password, + "-d", "master", + "-W", # trim trailing spaces + "-w", "65535", # do not wrap wide rows + "-s", "\t", # tab delimiter + ] + if not headers: + cmd += ["-h", "-1"] + cmd += ["-Q", sql] + r = subprocess.run(cmd, capture_output=True, timeout=300) + out = (r.stdout or b"").decode("utf-8", errors="replace") + err = (r.stderr or b"").decode("utf-8", errors="replace") + return out, err + + +def _esc(s): + """Escape a T-SQL single-quoted literal.""" + return s.replace("'", "''") + + +def run_perfcheck(server, password, args=""): + """Run sp_PerfCheck and return combined (stdout, stderr).""" + sql = "EXECUTE dbo.sp_PerfCheck %s;" % args if args else "EXECUTE dbo.sp_PerfCheck;" + return _sqlcmd(server, password, sql) + + +def run_perfcheck_with_option(server, password, name, value): + """Set one config option, RECONFIGURE, and run sp_PerfCheck in one batch so + the run is guaranteed to see the new value_in_use.""" + sql = ( + "SET NOCOUNT ON; " + "EXECUTE sys.sp_configure '%s', %d; " + "RECONFIGURE; " + "EXECUTE dbo.sp_PerfCheck;" + ) % (_esc(name), value) + return _sqlcmd(server, password, sql) + + +def set_option(server, password, name, value): + """Set one config option and RECONFIGURE. Returns any severe errors.""" + sql = ( + "SET NOCOUNT ON; " + "EXECUTE sys.sp_configure '%s', %d; " + "RECONFIGURE;" + ) % (_esc(name), value) + out, err = _sqlcmd(server, password, sql) + return find_sql_errors(out) + find_sql_errors(err) + + +def get_value_in_use(server, password, name): + """Return the current value_in_use of a config option as an int.""" + sql = ( + "SET NOCOUNT ON; " + "SELECT CONVERT(varchar(20), c.value_in_use) " + "FROM sys.configurations AS c WHERE c.name = '%s';" + ) % _esc(name) + out, _ = _sqlcmd(server, password, sql, headers=False) + for line in out.splitlines(): + line = line.strip() + if line and (line.lstrip("-").isdigit()): + return int(line) + raise RuntimeError("could not read value_in_use for %r; output=%r" % (name, out[:200])) + + +def snapshot_config(server, password): + """Return the full sys.configurations state as a sorted list of + 'name||value||value_in_use' lines, for a before/after zero-change diff.""" + sql = ( + "SET NOCOUNT ON; " + "SELECT CONVERT(nvarchar(200), c.name) + N'||' + " + "CONVERT(nvarchar(50), c.value) + N'||' + " + "CONVERT(nvarchar(50), c.value_in_use) " + "FROM sys.configurations AS c ORDER BY c.name;" + ) + out, _ = _sqlcmd(server, password, sql, headers=False) + return sorted(l.strip() for l in out.splitlines() if "||" in l) + + +def parse_result_rows(stdout): + """Return the #results data rows as lists of tab-split fields. + + A real #results row starts with an integer check_id and has at least 8 + fields (check_id .. details). Continuation lines produced by embedded CR/LF + inside a details value are only the tail of details plus url, so they carry + few tabs and are filtered out. #server_info rows start with a text + info_type, not a digit, so they are excluded too. + """ + rows = [] + for line in stdout.splitlines(): + fields = line.split("\t") + if len(fields) >= 8 and fields[0].strip().isdigit(): + rows.append([f.strip() for f in fields]) + return rows + + +def find_ctfp_finding(rows): + """Return the #results row for the Cost Threshold for Parallelism Too Low + finding (check_id 1004), or None.""" + for r in rows: + if r[4] == "Cost Threshold for Parallelism Too Low": + return r + return None + + +def find_db_finding(rows, finding_text, database_name): + """Return the #results row whose finding and database_name match, or None.""" + for r in rows: + if r[4] == finding_text and len(r) > 5 and r[5] == database_name: + return r + return None + + +def results_header_present(stdout): + """True if the #results result set header was emitted.""" + for line in stdout.splitlines(): + if "check_id" in line and "finding" in line and "url" in line: + return True + return False + + +def results_header_columns_ok(stdout): + """True if the #results header carries every expected column name.""" + for line in stdout.splitlines(): + if "check_id" in line and "finding" in line and "url" in line: + return all(col in line for col in RESULTS_COLUMNS) + return False + + +class Results: + def __init__(self): + self.items = [] + + def check(self, group, name, condition, detail=""): + self.items.append({ + "group": group, + "name": name, + "passed": bool(condition), + "detail": detail, + }) + + @property + def passed(self): + return sum(1 for r in self.items if r["passed"]) + + @property + def failed(self): + return sum(1 for r in self.items if not r["passed"]) + + +def structural_tests(server, password, R): + """Structural / smoke assertions: crashes and shape regressions.""" + # ---- Default-parameters run ----------------------------------------- + out, err = run_perfcheck(server, password) + combined = out + "\n" + err + errors = find_sql_errors(combined) + R.check("Smoke", "default run: no severe SQL error", + not errors, str(errors)) + R.check("Smoke", "default run: #server_info populated (Run Date present)", + SERVER_INFO_MARKER in out, "Run Date not found") + R.check("Smoke", "default run: #results result set emitted", + results_header_present(out), "results header not found") + R.check("Smoke", "default run: #results header has all expected columns", + results_header_columns_ok(out), "one or more columns missing") + + rows = parse_result_rows(out) + malformed = [] + for r in rows: + ok = (r[0].isdigit() and r[1].isdigit() and r[3] != "" and r[4] != "") + if not ok: + malformed.append(r[:5]) + R.check("Smoke", "default run: every #results row well-formed " + "(check_id, priority, category, finding)", + len(malformed) == 0, + "%d rows parsed, %d malformed: %s" % (len(rows), len(malformed), malformed[:3])) + + # ---- @help = 1 ------------------------------------------------------- + out, err = run_perfcheck(server, password, "@help = 1") + combined = out + "\n" + err + R.check("Smoke", "@help = 1: no severe SQL error", + not find_sql_errors(combined), "errors present") + R.check("Smoke", "@help = 1: returns help text", + "i am sp_PerfCheck" in out, "help text not found") + R.check("Smoke", "@help = 1: emits no findings (short-circuits)", + (SERVER_INFO_MARKER not in out) and (not results_header_present(out)), + "server_info or results were emitted under @help") + + # ---- @debug = 1 ------------------------------------------------------ + out, err = run_perfcheck(server, password, "@debug = 1") + combined = out + "\n" + err + R.check("Smoke", "@debug = 1: no severe SQL error", + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("Smoke", "@debug = 1: prints diagnostic messages", + DEBUG_MARKER in combined, "debug marker %r not found" % DEBUG_MARKER) + R.check("Smoke", "@debug = 1: still completes (Run Date present)", + SERVER_INFO_MARKER in out, "Run Date not found under @debug") + + +def forced_condition_tests(server, password, R): + """Bidirectional forced-condition assertions on the check_id 1004 Cost + Threshold for Parallelism check: ABSENT when CTFP is sane (>= 50), PRESENT + and High at the default of 5, PRESENT but only Low between 6 and 49. + Captures and restores the original value.""" + # Flush any pre-existing pending config change (value <> value_in_use) before + # baselining. Some images ship one: the SQL Server 2017 CI container has + # 'clr strict security' configured on but not yet in use. The harness's own + # RECONFIGURE calls would apply that staged change mid-run, and it would then + # surface as a spurious net change the harness never made. _sqlcmd does not + # raise on a SQL error, so a rejected RECONFIGURE is tolerated here. + _sqlcmd(server, password, "RECONFIGURE;", headers=False) + + # Snapshot the entire config before touching anything. + before = snapshot_config(server, password) + + saw_advanced = get_value_in_use(server, password, "show advanced options") + original_ctfp = get_value_in_use(server, password, CTFP) + + def restore_all(): + # Restore CTFP, then 'show advanced options'. RECONFIGURE promotes both. + _sqlcmd(server, password, + "SET NOCOUNT ON; EXECUTE sys.sp_configure '%s', %d; RECONFIGURE;" + % (_esc(CTFP), original_ctfp)) + _sqlcmd(server, password, + "SET NOCOUNT ON; EXECUTE sys.sp_configure 'show advanced options', %d; RECONFIGURE;" + % saw_advanced) + + grp = "Config[cost threshold for parallelism]" + try: + err = set_option(server, password, "show advanced options", 1) + R.check("Config", "setup: 'show advanced options' configurable", + not err, str(err)) + + # ---- ABSENT when CTFP is sane (>= 50) ------------------------------- + out, e = run_perfcheck_with_option(server, password, CTFP, CTFP_SANE) + combined = out + "\n" + e + R.check(grp, "no severe error on sane-value run", + not find_sql_errors(combined), str(find_sql_errors(combined))) + # positive control: the check machinery ran and produced output, so + # "absent" is not a vacuous pass. + R.check(grp, "positive control: #server_info populated at sane value", + SERVER_INFO_MARKER in out, "Run Date not found") + rows = parse_result_rows(out) + R.check(grp, "finding ABSENT when CTFP >= 50 (%d)" % CTFP_SANE, + find_ctfp_finding(rows) is None, + "unexpected CTFP finding at a sane value") + + # ---- PRESENT and HIGH at the default of 5 -------------------------- + out, e = run_perfcheck_with_option(server, password, CTFP, CTFP_DEFAULT) + combined = out + "\n" + e + R.check(grp, "no severe error on default-value run", + not find_sql_errors(combined), str(find_sql_errors(combined))) + rows = parse_result_rows(out) + row = find_ctfp_finding(rows) + R.check(grp, "finding PRESENT when CTFP = default (%d)" % CTFP_DEFAULT, + row is not None, "finding not emitted at the default") + if row is not None: + well_formed = (row[0] == "1004" and row[1] == "20" + and row[3] == "Server Configuration" + and row[4] == "Cost Threshold for Parallelism Too Low") + R.check(grp, "default finding well-formed " + "(check_id 1004, High priority 20, Server Configuration)", + well_formed, + "check_id=%s priority=%s finding=%r" + % (row[0], row[1], row[4])) + details = row[7] if len(row) > 7 else "" + R.check(grp, "default finding details name the value", + ("set to %d" % CTFP_DEFAULT) in details, + "details=%r" % details[:160]) + else: + R.check(grp, "default finding well-formed " + "(check_id 1004, High priority 20, Server Configuration)", + False, "no row to inspect") + R.check(grp, "default finding details name the value", + False, "no row to inspect") + + # ---- PRESENT but only LOW between 6 and 49 ------------------------- + out, e = run_perfcheck_with_option(server, password, CTFP, CTFP_LOW) + rows = parse_result_rows(out) + row = find_ctfp_finding(rows) + R.check(grp, "finding PRESENT when CTFP low-but-not-default (%d)" % CTFP_LOW, + row is not None, "finding not emitted at a low value") + R.check(grp, "priority is Low (40), not High, when CTFP is 6-49", + row is not None and row[1] == "40", + "priority=%s" % (row[1] if row else "no row")) + + # ---- Restore CTFP to its exact original ---------------------------- + set_option(server, password, CTFP, original_ctfp) + now = get_value_in_use(server, password, CTFP) + R.check(grp, "option restored to original value_in_use (%d)" % original_ctfp, + now == original_ctfp, "value_in_use is now %d" % now) + finally: + # Safety net: restore everything even if an exception was raised. + restore_all() + + # Zero net configuration change after the whole run. + after = snapshot_config(server, password) + diff = [x for x in after if x not in set(before)] + \ + [x for x in before if x not in set(after)] + R.check("Config", "zero net configuration change (sys.configurations before == after)", + before == after, + "differences: %s" % diff[:6] if diff else "") + + +def database_scoped_tests(server, password, R): + """Bidirectional forced-condition assertions on two database-level checks + that read pure metadata: Auto-Shrink Enabled (check_id 7001) and Auto Update + Statistics Disabled (check_id 7004). Both are instant, reversible ALTER + DATABASE settings, so a scratch database can force each on and off + deterministically, and both leave the database OPEN. + + (AUTO_CLOSE is deliberately NOT used for the ON/OFF finding toggles here, + because closing the database changes which findings are even assessable. The + crash it used to cause -- a closed or offline database made sys.databases + return NULL for is_accelerated_database_recovery_on, which the #databases + insert rejected with Msg 515 -- is now fixed, and inaccessible_database_tests + below asserts it stays fixed.) + + The design keeps every absence assertion non-vacuous WITHOUT relying on any + volatile finding: in each run one toggle is set to its finding-producing + state and the other is not, so the finding that IS present (scoped to the + scratch database by name) proves the database was actually analyzed -- the + positive control -- while the other finding's absence is therefore real, not + the result of the database being skipped. Across the two runs each toggle is + proven both present and absent. + """ + db = "perfcheck_test_scratch" + grp = "DbConfig" + SHRINK = "Auto-Shrink Enabled" + STATS = "Auto Update Statistics Disabled" + + drop_sql = ( + "SET NOCOUNT ON; " + "IF DB_ID('%s') IS NOT NULL " + "BEGIN " + "ALTER DATABASE [%s] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " + "DROP DATABASE [%s]; " + "END;" + ) % (db, db, db) + + def scoped_run(): + return run_perfcheck(server, password, "@database_name = N'%s'" % db) + + # Idempotent setup: drop any leaked copy, then create fresh. + out, err = _sqlcmd(server, password, drop_sql + " CREATE DATABASE [%s];" % db) + setup_err = find_sql_errors(out) + find_sql_errors(err) + R.check(grp, "setup: scratch database created", + not setup_err and DB_marker(server, password, db), str(setup_err)) + if setup_err: + _sqlcmd(server, password, drop_sql) + return + + try: + # ---- Run A: AUTO_SHRINK ON, AUTO_UPDATE_STATISTICS ON ----------- + _sqlcmd(server, password, + "ALTER DATABASE [%s] SET AUTO_SHRINK ON; " + "ALTER DATABASE [%s] SET AUTO_UPDATE_STATISTICS ON;" % (db, db)) + out, err = scoped_run() + R.check(grp, "run A: no severe SQL error", + not (find_sql_errors(out) + find_sql_errors(err)), + str(find_sql_errors(out) + find_sql_errors(err))) + R.check(grp, "run A: #server_info populated", + SERVER_INFO_MARKER in out, "Run Date not found") + rows = parse_result_rows(out) + shrink = find_db_finding(rows, SHRINK, db) + stats = find_db_finding(rows, STATS, db) + # Auto-Shrink PRESENT: this both proves the toggle fires AND proves the + # scratch database was analyzed (positive control for the absence below). + R.check(grp, "Auto-Shrink PRESENT when forced on (proves db analyzed)", + shrink is not None, "Auto-Shrink finding missing when forced on") + if shrink is not None: + R.check(grp, "Auto-Shrink row well-formed (check_id 7001, db scoped)", + shrink[0] == "7001" and shrink[3] == "Database Configuration", + "check_id=%s category=%s" % (shrink[0], shrink[3])) + R.check(grp, "Auto-Update-Stats-Disabled ABSENT when stats on " + "(db was analyzed, so non-vacuous)", + stats is None, "stats-disabled finding present when stats on") + + # ---- Run B: AUTO_SHRINK OFF, AUTO_UPDATE_STATISTICS OFF --------- + _sqlcmd(server, password, + "ALTER DATABASE [%s] SET AUTO_SHRINK OFF; " + "ALTER DATABASE [%s] SET AUTO_UPDATE_STATISTICS OFF;" % (db, db)) + out, err = scoped_run() + R.check(grp, "run B: no severe SQL error", + not (find_sql_errors(out) + find_sql_errors(err)), + str(find_sql_errors(out) + find_sql_errors(err))) + rows = parse_result_rows(out) + shrink = find_db_finding(rows, SHRINK, db) + stats = find_db_finding(rows, STATS, db) + # Stats-disabled PRESENT is now the positive control that db was analyzed. + R.check(grp, "Auto-Update-Stats-Disabled PRESENT when stats off " + "(proves db analyzed)", + stats is not None, "stats-disabled finding missing when stats off") + if stats is not None: + R.check(grp, "Stats-disabled row well-formed (check_id 7004, db scoped)", + stats[0] == "7004" and stats[3] == "Database Configuration", + "check_id=%s category=%s" % (stats[0], stats[3])) + R.check(grp, "Auto-Shrink ABSENT when off (db was analyzed, so non-vacuous)", + shrink is None, "Auto-Shrink finding present when off") + finally: + # Always drop the scratch database, even on assertion failure. + _sqlcmd(server, password, drop_sql) + R.check(grp, "cleanup: scratch database dropped", + not DB_marker(server, password, db), "scratch database still exists") + + +def inaccessible_database_tests(server, password, R): + """Regression test for a Msg 515 crash on inaccessible databases. + + sp_PerfCheck's #databases collection has no state filter, so it inspects + every user database regardless of accessibility. A database that is closed + (AUTO_CLOSE) or OFFLINE returns NULL for is_accelerated_database_recovery_on + in sys.databases -- ADR state lives with the database, not master metadata, + so it cannot be read while the database is shut. That column used to be + NOT NULL in #databases, so the collection INSERT rejected the NULL with + Msg 515 and aborted the entire check. One inaccessible database could take + down a whole-instance run. The column is now nullable; these assertions prove + the procedure completes cleanly instead of crashing. + """ + db = "perfcheck_test_inaccessible" + grp = "Inaccessible" + + drop_sql = ( + "SET NOCOUNT ON; " + "IF DB_ID('%s') IS NOT NULL " + "BEGIN " + "ALTER DATABASE [%s] SET ONLINE; " + "ALTER DATABASE [%s] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " + "DROP DATABASE [%s]; " + "END;" + ) % (db, db, db, db) + + out, err = _sqlcmd(server, password, drop_sql + " CREATE DATABASE [%s];" % db) + R.check(grp, "setup: scratch database created", + not (find_sql_errors(out) + find_sql_errors(err)) + and DB_marker(server, password, db), + "scratch database not created") + + try: + # AUTO_CLOSE, then force it shut with an offline/online cycle so the next + # metadata read sees a closed database. + _sqlcmd(server, password, "ALTER DATABASE [%s] SET AUTO_CLOSE ON;" % db) + _sqlcmd(server, password, + "ALTER DATABASE [%s] SET OFFLINE WITH ROLLBACK IMMEDIATE; " + "ALTER DATABASE [%s] SET ONLINE;" % (db, db)) + out, err = run_perfcheck(server, password, "@database_name = N'%s'" % db) + errs = find_sql_errors(out) + find_sql_errors(err) + R.check(grp, "AUTO_CLOSE-closed database scoped run does not crash (Msg 515)", + not errs, str(errs)) + + # OFFLINE is the more severe case, and the one a whole-instance run hits. + _sqlcmd(server, password, "ALTER DATABASE [%s] SET OFFLINE WITH ROLLBACK IMMEDIATE;" % db) + out, err = run_perfcheck(server, password, "@database_name = N'%s'" % db) + errs = find_sql_errors(out) + find_sql_errors(err) + R.check(grp, "OFFLINE database scoped run does not crash (Msg 515)", + not errs, str(errs)) + + # The load-bearing case: a whole-instance run must not be aborted by one + # inaccessible database sitting on the server. + out, err = run_perfcheck(server, password) + errs = find_sql_errors(out) + find_sql_errors(err) + R.check(grp, "whole-instance run with an OFFLINE database present does not crash", + not errs, str(errs)) + finally: + _sqlcmd(server, password, drop_sql) + R.check(grp, "cleanup: scratch database dropped", + not DB_marker(server, password, db), "scratch database still exists") + + +def DB_marker(server, password, db): + """True if the named database currently exists.""" + out, _ = _sqlcmd( + server, password, + "SET NOCOUNT ON; SELECT CASE WHEN DB_ID('%s') IS NOT NULL " + "THEN 'YES' ELSE 'NO' END;" % _esc(db), + headers=False, + ) + return "YES" in out + + +def preflight(server, password): + """Confirm sp_PerfCheck is installed before running anything.""" + out, err = _sqlcmd( + server, password, + "SET NOCOUNT ON; SELECT CASE WHEN OBJECT_ID(N'dbo.sp_PerfCheck', N'P') " + "IS NOT NULL THEN 'OK' ELSE 'MISSING' END;", + headers=False, + ) + return "OK" in out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server", default="SQL2022") + ap.add_argument("--password", default="L!nt0044") + args = ap.parse_args() + + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + print("Running sp_PerfCheck assertion tests against %s..." % args.server) + print() + + if not preflight(args.server, args.password): + print("ERROR: dbo.sp_PerfCheck is not installed in master on %s." % args.server) + print("Install sp_PerfCheck.sql before running this harness.") + sys.exit(1) + + R = Results() + structural_tests(args.server, args.password, R) + forced_condition_tests(args.server, args.password, R) + database_scoped_tests(args.server, args.password, R) + inaccessible_database_tests(args.server, args.password, R) + + for r in R.items: + status = "PASS" if r["passed"] else "FAIL" + detail = (" (%s)" % r["detail"]) if (not r["passed"] and r["detail"]) else "" + print(" [%s] %s: %s%s" % (status, r["group"], r["name"], detail)) + + print() + print("Results: %d passed, %d failed, %d total" % (R.passed, R.failed, len(R.items))) + + if R.failed > 0: + print() + print("FAILED TESTS:") + for r in R.items: + if not r["passed"]: + print(" %s: %s (%s)" % (r["group"], r["name"], r["detail"])) + sys.exit(1) + else: + print("All tests passed!") + + +if __name__ == "__main__": + main() diff --git a/sp_PressureDetector/README.md b/sp_PressureDetector/README.md index c06c9f45..6c33b34f 100644 --- a/sp_PressureDetector/README.md +++ b/sp_PressureDetector/README.md @@ -27,11 +27,11 @@ All you need to do is hit F5 to get information about: | @minimum_disk_latency_ms | smallint | low bound for reporting disk latency | a reasonable number of milliseconds for disk latency | 100 | | @cpu_utilization_threshold | smallint | low bound for reporting high cpu utlization | a reasonable cpu utlization percentage | 50 | | @skip_waits | bit | skips waits when you do not need them on every run | 0 or 1 | 0 | -| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | a valid tinyint: 0-255 | 0 | -| @sample_seconds | tinyint | take a sample of your server's metrics | 0 or 1 | 0 | -| @log_to_table | bit | enable logging to permanent tables | 0 or 1 | 0 | -| @log_database_name | sysname | database to store logging tables | valid database name | NULL | -| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL | +| @skip_perfmon | bit | skips perfmon counters when you do not need them on every run | 0 or 1 | 0 | +| @sample_seconds | tinyint | take a sample of your server's metrics | a valid tinyint: 0-255 | 0 | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | valid database name | NULL (current database) | +| @log_schema_name | sysname | schema to store logging tables | valid schema name | NULL (dbo) | | @log_table_name_prefix | sysname | prefix for all logging tables | valid table name prefix | 'PressureDetector' | | @log_retention_days | integer | Number of days to keep logs, 0 = keep indefinitely | integer | 30 | | @troubleshoot_blocking | bit | show blocking chains instead of pressure analysis | 0 or 1 | 0 | diff --git a/sp_PressureDetector/sp_PressureDetector.sql b/sp_PressureDetector/sp_PressureDetector.sql index 96b83c54..eb14363c 100644 --- a/sp_PressureDetector/sp_PressureDetector.sql +++ b/sp_PressureDetector/sp_PressureDetector.sql @@ -53,7 +53,7 @@ ALTER PROCEDURE @skip_queries bit = 0, /*if you want to skip looking at running queries*/ @skip_plan_xml bit = 0, /*if you want to skip getting plan XML*/ @minimum_disk_latency_ms smallint = 100, /*low bound for reporting disk latency*/ - @cpu_utilization_threshold smallint = 50, /*low bound for reporting high cpu utlization*/ + @cpu_utilization_threshold smallint = 50, /*low bound for reporting high cpu utilization*/ @skip_waits bit = 0, /*skips waits when you do not need them on every run*/ @skip_perfmon bit = 0, /*skips perfmon counters when you do not need them on every run*/ @sample_seconds tinyint = 0, /*take a sample of your server's metrics*/ @@ -78,8 +78,8 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET LANGUAGE us_english; SELECT - @version = '6.7', - @version_date = '20260701'; + @version = '6.8', + @version_date = '20260801'; IF @help = 1 @@ -113,7 +113,7 @@ BEGIN WHEN N'@skip_queries' THEN N'if you want to skip looking at running queries' WHEN N'@skip_plan_xml' THEN N'if you want to skip getting plan XML' WHEN N'@minimum_disk_latency_ms' THEN N'low bound for reporting disk latency' - WHEN N'@cpu_utilization_threshold' THEN N'low bound for reporting high cpu utlization' + WHEN N'@cpu_utilization_threshold' THEN N'low bound for reporting high cpu utilization' WHEN N'@skip_waits' THEN N'skips waits when you do not need them on every run' WHEN N'@skip_perfmon' THEN N'skips perfmon counters when you do not need them on every run' WHEN N'@sample_seconds' THEN N'take a sample of your server''s metrics' @@ -135,14 +135,14 @@ BEGIN WHEN N'@skip_queries' THEN N'0 or 1' WHEN N'@skip_plan_xml' THEN N'0 or 1' WHEN N'@minimum_disk_latency_ms' THEN N'a reasonable number of milliseconds for disk latency' - WHEN N'@cpu_utilization_threshold' THEN N'a reasonable cpu utlization percentage' + WHEN N'@cpu_utilization_threshold' THEN N'a reasonable cpu utilization percentage' WHEN N'@skip_waits' THEN N'0 or 1' WHEN N'@skip_perfmon' THEN N'0 or 1' WHEN N'@sample_seconds' THEN N'a valid tinyint: 0-255' WHEN N'@log_to_table' THEN N'0 or 1' WHEN N'@log_database_name' THEN N'any valid database name' WHEN N'@log_schema_name' THEN N'any valid schema name' - WHEN N'@log_table_name_prefix' THEN N'any valid identifier' + WHEN N'@log_table_name_prefix' THEN N'any valid identifier, 112 characters or fewer' WHEN N'@log_retention_days' THEN N'a positive integer' WHEN N'@help' THEN N'0 or 1' WHEN N'@troubleshoot_blocking' THEN N'0 or 1' @@ -409,6 +409,16 @@ OPTION(MAXDOP 1, RECOMPILE);', @database_size_out_gb nvarchar(10) = '0', @total_physical_memory_gb bigint, @cpu_utilization xml = N'', + /* + A second, separate CPU signal: the box saturated by processes + OTHER than SQL Server. The panel already computes it, but only + ever filtered on SQL's own utilization, so a machine pegged by a + different instance / AV / backup agent reported "no significant + CPU usage" while SQL was being scheduled off the CPU. Reported + alongside the SQL-CPU number, not folded into it. + */ + @cpu_utilization_external xml = N'', + @pd_catch_msg nvarchar(2048), @low_memory xml = N'', @health_history bit = CASE @@ -464,15 +474,22 @@ OPTION(MAXDOP 1, RECOMPILE);', @resource_semaphores nvarchar(max) = N'', @cpu_threads nvarchar(max) = N'', /*Log to table stuff*/ - @log_table_waits sysname, - @log_table_file_metrics sysname, - @log_table_perfmon sysname, - @log_table_memory sysname, - @log_table_cpu sysname, - @log_table_memory_consumers sysname, - @log_table_memory_queries sysname, - @log_table_cpu_queries sysname, - @log_table_cpu_events sysname, + /* + nvarchar(1024), not sysname: these hold fully-qualified, + QUOTENAME'd three-part names. sysname is 128 chars, so a + long-but-legal database/schema/prefix silently truncated the + name and the CREATE TABLE then failed partway through setup, + after some of the nine tables already existed. + */ + @log_table_waits nvarchar(1024), + @log_table_file_metrics nvarchar(1024), + @log_table_perfmon nvarchar(1024), + @log_table_memory nvarchar(1024), + @log_table_cpu nvarchar(1024), + @log_table_memory_consumers nvarchar(1024), + @log_table_memory_queries nvarchar(1024), + @log_table_cpu_queries nvarchar(1024), + @log_table_cpu_events nvarchar(1024), @cleanup_date datetime2(7), @max_sample_time datetime, @check_sql nvarchar(max) = N'', @@ -489,7 +506,30 @@ OPTION(MAXDOP 1, RECOMPILE);', /* Default database name to current database if not specified */ @log_database_name = ISNULL(@log_database_name, DB_NAME()), /* Default schema name to dbo if not specified */ - @log_schema_name = ISNULL(@log_schema_name, N'dbo'); + @log_schema_name = ISNULL(@log_schema_name, N'dbo'), + /* + Default the prefix too: it is the one log identifier that + QUOTENAME touches unguarded, and a NULL prefix (say, an + automation variable that resolves to NULL) nulls every + CREATE / INSERT / DELETE command string, which sp_executesql + runs as a silent no-op. In logging mode every result set is + suppressed, so nothing would announce that no tables were + created and nothing was logged. + */ + @log_table_name_prefix = ISNULL(@log_table_name_prefix, N'PressureDetector'); + + /* + Cap the prefix so prefix + the longest suffix (_MemoryConsumers, + 16 chars) still fits a 128-char identifier. Past that, QUOTENAME + returns NULL for the long leaves and the run half-creates its + tables before failing - fail loudly up front instead. DATALENGTH + so trailing spaces cannot slip past the limit. + */ + IF DATALENGTH(@log_table_name_prefix) / 2 > 112 + BEGIN + RAISERROR(N'@log_table_name_prefix is limited to 112 characters so the longest table suffix (_MemoryConsumers) still fits in an identifier. Logging will be disabled.', 11, 1) WITH NOWAIT; + RETURN; + END; /* Validate database exists */ IF NOT EXISTS @@ -593,7 +633,7 @@ OPTION(MAXDOP 1, RECOMPILE);', waiting_tasks_count bigint NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for wait stats logging.'', 0, 1, ''' + @log_table_waits + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for wait stats logging.'', 0, 1, ''' + REPLACE(@log_table_waits, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -642,7 +682,7 @@ OPTION(MAXDOP 1, RECOMPILE);', io_stall_write_ms bigint NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for file metrics logging.'', 0, 1, ''' + @log_table_file_metrics + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for file metrics logging.'', 0, 1, ''' + REPLACE(@log_table_file_metrics, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -678,7 +718,7 @@ OPTION(MAXDOP 1, RECOMPILE);', cntr_type bigint NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for perfmon logging.'', 0, 1, ''' + @log_table_perfmon + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for perfmon logging.'', 0, 1, ''' + REPLACE(@log_table_perfmon, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -726,7 +766,7 @@ OPTION(MAXDOP 1, RECOMPILE);', pool_id integer NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory logging.'', 0, 1, ''' + @log_table_memory + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory logging.'', 0, 1, ''' + REPLACE(@log_table_memory, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -771,7 +811,7 @@ OPTION(MAXDOP 1, RECOMPILE);', cpu_utilization_over_threshold xml NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU logging.'', 0, 1, ''' + @log_table_cpu + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU logging.'', 0, 1, ''' + REPLACE(@log_table_cpu, N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -805,7 +845,7 @@ OPTION(MAXDOP 1, RECOMPILE);', memory_consumed_gb decimal(38,2) NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory consumers logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryConsumers') + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory consumers logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryConsumers'), N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -860,7 +900,7 @@ OPTION(MAXDOP 1, RECOMPILE);', live_query_plan xml NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory queries logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryQueries') + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for memory queries logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_MemoryQueries'), N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -914,7 +954,7 @@ OPTION(MAXDOP 1, RECOMPILE);', statement_end_offset integer NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU queries logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUQueries') + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU queries logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUQueries'), N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -950,7 +990,7 @@ OPTION(MAXDOP 1, RECOMPILE);', total_cpu_utilization integer NULL, PRIMARY KEY CLUSTERED (collection_time, id) ); - IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU utilization events logging.'', 0, 1, ''' + @log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUEvents') + N''') WITH NOWAIT; END; + IF @debug = 1 BEGIN RAISERROR(''Created table %s for CPU utilization events logging.'', 0, 1, ''' + REPLACE(@log_database_schema + QUOTENAME(@log_table_name_prefix + N'_CPUEvents'), N'''', N'''''') + N''') WITH NOWAIT; END; END'; EXECUTE sys.sp_executesql @@ -1944,7 +1984,8 @@ OPTION(MAXDOP 1, RECOMPILE);', CONVERT ( decimal(38, 2), - (fm2.io_stall_read_ms - fm.io_stall_read_ms) / + /* 1.0 * forces decimal division; bigint/bigint truncated every latency to a whole ms (and to 0 on fast disks) */ + 1.0 * (fm2.io_stall_read_ms - fm.io_stall_read_ms) / (fm2.total_read_count - fm.total_read_count) ) END, @@ -1956,7 +1997,7 @@ OPTION(MAXDOP 1, RECOMPILE);', CONVERT ( decimal(38, 2), - (fm2.io_stall_write_ms - fm.io_stall_write_ms) / + 1.0 * (fm2.io_stall_write_ms - fm.io_stall_write_ms) / (fm2.total_write_count - fm.total_write_count) ) END, @@ -1969,6 +2010,7 @@ OPTION(MAXDOP 1, RECOMPILE);', CONVERT ( decimal(38,2), + 1.0 * ( (fm2.io_stall_read_ms - fm.io_stall_read_ms) + (fm2.io_stall_write_ms - fm.io_stall_write_ms) @@ -2269,7 +2311,17 @@ OPTION(MAXDOP 1, RECOMPILE);', p.total, p.total_per_second FROM p - WHERE p.total_per_second <> N'0' + /* + Filter on the raw counter, not the per-second STRING. + total_per_second is cntr_value / uptime-in-seconds in + integer arithmetic, so any counter smaller than the + server's uptime floors to '0', and the counters that sit + at single digits (Memory Grants Pending, Processes + Blocked, deadlocks) are exactly the point-in-time + pressure gauges. The load step already keeps only + cntr_value > 0, so this filter matches it. + */ + WHERE p.cntr_value > 0 ORDER BY p.object_name, p.counter_name, @@ -3385,6 +3437,15 @@ OPTION(MAXDOP 1, RECOMPILE);', deqs.query_plan,' ELSE N'' END + /* + This ELSE is load-bearing: without it, @skip_plan_xml + = 1 makes the outer CASE return NULL, CONVERT of NULL + is NULL, and the whole @mem_sql string collapses to + NULL, silently discarding the entire "queries with + memory grants" result set. The CPU builder carries the + same ELSE for the same reason. + */ + ELSE N'' END + N' deqmg.request_time, @@ -3472,8 +3533,22 @@ OPTION(MAXDOP 1, RECOMPILE);', IF @log_to_table = 0 BEGIN - EXECUTE sys.sp_executesql - @mem_sql; + /* + TRY/CATCH so a failure fetching query plans does not take the + whole report down with it. This batch SETs LOCK_TIMEOUT to + fail fast, and with XACT_ABORT ON an unhandled lock timeout + (error 1222, plausible under the very plan-cache churn being + diagnosed) would abort the entire procedure, losing every + section after this one. Degrade to a warning instead. + */ + BEGIN TRY + EXECUTE sys.sp_executesql + @mem_sql; + END TRY + BEGIN CATCH + SET @pd_catch_msg = ERROR_MESSAGE(); + RAISERROR(N'Memory-grant queries skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT; + END CATCH; END IF @log_to_table = 1 @@ -3539,8 +3614,15 @@ OPTION(MAXDOP 1, RECOMPILE);', PRINT @insert_sql; END; - EXECUTE sys.sp_executesql - @insert_sql; + /* Same plan-fetch TRY/CATCH as the client path above. */ + BEGIN TRY + EXECUTE sys.sp_executesql + @insert_sql; + END TRY + BEGIN CATCH + SET @pd_catch_msg = ERROR_MESSAGE(); + RAISERROR(N'Memory-grant query logging skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT; + END CATCH; END; END; @@ -3634,8 +3716,21 @@ OPTION(MAXDOP 1, RECOMPILE);', @cpu_details_output OUTPUT; /* - Checking for high CPU utilization periods + Checking for high CPU utilization periods. + Both CPU documents below must stamp identical sample_times for + the UNION in the logging insert to collapse samples that appear + in each, so the two moving inputs are frozen once here instead + of each query reading SYSDATETIME() and ms_ticks at its own + moment and stamping the same ring buffer record differently */ + DECLARE + @utilization_now datetime2(7) = SYSDATETIME(), + @utilization_ms_ticks bigint; + + SELECT + @utilization_ms_ticks = osi.ms_ticks + FROM sys.dm_os_sys_info AS osi; + SELECT @cpu_utilization = x.cpu_utilization @@ -3649,8 +3744,8 @@ OPTION(MAXDOP 1, RECOMPILE);', DATEADD ( SECOND, - (t.timestamp - osi.ms_ticks) / 1000, - SYSDATETIME() + (t.timestamp - @utilization_ms_ticks) / 1000, + @utilization_now ) ), sqlserver_cpu_utilization = @@ -3660,8 +3755,7 @@ OPTION(MAXDOP 1, RECOMPILE);', - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]','integer')), total_cpu_utilization = (100 - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'integer')) - FROM sys.dm_os_sys_info AS osi - CROSS JOIN + FROM ( SELECT dorb.timestamp, @@ -3692,13 +3786,77 @@ OPTION(MAXDOP 1, RECOMPILE);', ); END; + /* + The same panel again, but keyed on OTHER-process CPU rather than + SQL Server's own. Separate variable, separate result column: a + DBA can see "SQL is hot" and "something else on the box is hot" + as two distinct answers instead of one masking the other. + */ + SELECT + @cpu_utilization_external = + x.cpu_utilization + FROM + ( + SELECT + sample_time = + CONVERT + ( + datetime, + DATEADD + ( + SECOND, + (t.timestamp - @utilization_ms_ticks) / 1000, + @utilization_now + ) + ), + sqlserver_cpu_utilization = + t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]','integer'), + other_process_cpu_utilization = + (100 - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/ProcessUtilization)[1]','integer') + - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]','integer')), + total_cpu_utilization = + (100 - t.record.value('(Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', 'integer')) + FROM + ( + SELECT + dorb.timestamp, + record = + CONVERT(xml, dorb.record) + FROM sys.dm_os_ring_buffers AS dorb + WHERE dorb.ring_buffer_type = N'RING_BUFFER_SCHEDULER_MONITOR' + ) AS t + /* Each value forced to a singleton with [1] before the arithmetic; XQuery treats a bare child step as a sequence and rejects '-' on it */ + WHERE t.record.exist('(Record/SchedulerMonitorEvent/SystemHealth)[(100 - (ProcessUtilization)[1] - (SystemIdle)[1]) >= sql:variable("@cpu_utilization_threshold")]') = 1 + ORDER BY + sample_time DESC + FOR XML + PATH('cpu_utilization'), + TYPE + ) AS x (cpu_utilization) + OPTION(MAXDOP 1, RECOMPILE); + + IF @cpu_utilization_external IS NULL + BEGIN + SELECT + @cpu_utilization_external = + ( + SELECT + N'No significant non-SQL Server CPU usage data available.' + FOR XML + PATH(N'cpu_utilization'), + TYPE + ); + END; + IF @log_to_table = 0 BEGIN SELECT cpu_details_output = @cpu_details_output, cpu_utilization_over_threshold = - @cpu_utilization; + @cpu_utilization, + external_cpu_utilization_over_threshold = + @cpu_utilization_external; END; IF @log_to_table = 1 BEGIN @@ -3724,6 +3882,18 @@ OPTION(MAXDOP 1, RECOMPILE);', N'@max_sample_time_out datetime OUTPUT', @max_sample_time OUTPUT; + /* + UNION both CPU signals into the same insert. The event table + already carries other_process_cpu_utilization and + total_cpu_utilization, but the SQL-CPU XML only holds samples + that tripped SQL's own threshold, so a sample where an + external process pegs the box while SQL sits idle appears + only in the external XML. Both documents stamp identical + sample_times from the inputs frozen above, which is what + lets UNION (not UNION ALL) collapse samples that tripped + both. The no-data placeholder XML has no sample_time node, + so its exist() filter is false and it contributes nothing. + */ SET @insert_sql = N' SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; INSERT INTO ' + @log_table_cpu_events + N' @@ -3739,6 +3909,16 @@ OPTION(MAXDOP 1, RECOMPILE);', other_process_cpu_utilization = event.value(''(./other_process_cpu_utilization)[1]'', ''integer''), total_cpu_utilization = event.value(''(./total_cpu_utilization)[1]'', ''integer'') FROM @cpu_utilization.nodes(''/cpu_utilization'') AS cpu(event) + WHERE event.exist(''(./sample_time)[. > sql:variable("@max_sample_time")]'') = 1 + + UNION + + SELECT + sample_time = event.value(''(./sample_time)[1]'', ''datetime''), + sqlserver_cpu_utilization = event.value(''(./sqlserver_cpu_utilization)[1]'', ''integer''), + other_process_cpu_utilization = event.value(''(./other_process_cpu_utilization)[1]'', ''integer''), + total_cpu_utilization = event.value(''(./total_cpu_utilization)[1]'', ''integer'') + FROM @cpu_utilization_external.nodes(''/cpu_utilization'') AS cpu(event) WHERE event.exist(''(./sample_time)[. > sql:variable("@max_sample_time")]'') = 1;'; IF @debug = 1 @@ -3749,8 +3929,10 @@ OPTION(MAXDOP 1, RECOMPILE);', EXECUTE sys.sp_executesql @insert_sql, N'@cpu_utilization xml, + @cpu_utilization_external xml, @max_sample_time datetime', @cpu_utilization, + @cpu_utilization_external, @max_sample_time; END; @@ -3790,7 +3972,7 @@ OPTION(MAXDOP 1, RECOMPILE);', total_active_parallel_thread_count = MAX(wg.active_parallel_thread_count), avg_runnable_tasks_count = - AVG(dos.runnable_tasks_count), + AVG(1. * dos.runnable_tasks_count), high_runnable_percent = MAX(ISNULL(r.high_runnable_percent, 0)) FROM sys.dm_os_schedulers AS dos @@ -3829,8 +4011,8 @@ OPTION(MAXDOP 1, RECOMPILE);', ( x.runnable / (1. * NULLIF(x.total, 0)) - ) - ) * 100. + ) * 100. + ) FROM ( SELECT @@ -4188,8 +4370,15 @@ OPTION(MAXDOP 1, RECOMPILE);', IF @log_to_table = 0 BEGIN - EXECUTE sys.sp_executesql - @cpu_sql; + /* Same plan-fetch TRY/CATCH as the memory-grants path. */ + BEGIN TRY + EXECUTE sys.sp_executesql + @cpu_sql; + END TRY + BEGIN CATCH + SET @pd_catch_msg = ERROR_MESSAGE(); + RAISERROR(N'CPU queries skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT; + END CATCH; END; IF @log_to_table = 1 @@ -4254,8 +4443,15 @@ OPTION(MAXDOP 1, RECOMPILE);', PRINT @insert_sql; END; - EXECUTE sys.sp_executesql - @insert_sql; + /* Same plan-fetch TRY/CATCH as the client path above. */ + BEGIN TRY + EXECUTE sys.sp_executesql + @insert_sql; + END TRY + BEGIN CATCH + SET @pd_catch_msg = ERROR_MESSAGE(); + RAISERROR(N'CPU query logging skipped: %s', 10, 1, @pd_catch_msg) WITH NOWAIT; + END CATCH; END; END; /*End not skipping queries*/ END; /*End CPU checks*/ diff --git a/sp_QueryReproBuilder/README.md b/sp_QueryReproBuilder/README.md index 91e659fd..198a3567 100644 --- a/sp_QueryReproBuilder/README.md +++ b/sp_QueryReproBuilder/README.md @@ -21,7 +21,7 @@ You can filter queries by plan_id or query_id, and optionally specify a date ran |------------------------|--------------------|-----------------------------------------------------------------------|----------------------------------------------------|----------------------------------| | @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database if NULL | | @start_date | datetimeoffset(7) | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days | -| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | current date/time | +| @end_date | datetimeoffset(7) | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL | | @include_plan_ids | nvarchar(4000) | a list of plan ids to search for | a string; comma separated for multiple ids | NULL | | @include_query_ids | nvarchar(4000) | a list of query ids to search for | a string; comma separated for multiple ids | NULL | | @include_query_hashes | nvarchar(4000) | a list of query hashes to search for | a string; comma separated for multiple hashes | NULL | @@ -32,10 +32,11 @@ You can filter queries by plan_id or query_id, and optionally specify a date ran | @ignore_query_hashes | nvarchar(4000) | a list of query hashes to ignore | a string; comma separated for multiple hashes | NULL | | @ignore_plan_hashes | nvarchar(4000) | a list of query plan hashes to ignore | a string; comma separated for multiple hashes | NULL | | @ignore_sql_handles | nvarchar(4000) | a list of sql handles to ignore | a string; comma separated for multiple handles | NULL | -| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL | +| @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL | | @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database | NULL | -| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards added | NULL | -| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards added | NULL | +| @query_text_search | nvarchar(4000) | query text to search for | a string; leading and trailing wildcards will be added if missing | NULL | +| @query_text_search_not | nvarchar(4000) | query text to exclude | a string; leading and trailing wildcards will be added if missing | NULL | +| @query_plan_xml | xml | a single query plan XML to process directly, bypassing Query Store | a valid ShowPlanXML document; when supplied, all Query Store filters are ignored | NULL | | @help | bit | return available parameter details, etc. | 0 or 1 | 0 | | @debug | bit | prints dynamic sql, statement length, parameter and variable values | 0 or 1 | 0 | | @version | varchar(30) | OUTPUT; for support | none; OUTPUT | none; OUTPUT | diff --git a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql index 2710fdfc..99b37f15 100644 --- a/sp_QueryReproBuilder/sp_QueryReproBuilder.sql +++ b/sp_QueryReproBuilder/sp_QueryReproBuilder.sql @@ -90,8 +90,8 @@ BEGIN TRY /*Version*/ SELECT - @version = '1.7', - @version_date = '20260701'; + @version = '1.8', + @version_date = '20260801'; /*Help*/ IF @help = 1 @@ -105,7 +105,7 @@ BEGIN SELECT 'i extract query text and parameters from query plans' UNION ALL SELECT 'and set them up to run with sp_executesql' UNION ALL SELECT '' UNION ALL - SELECT 'from your loving sql server consultant, erik darling: erikdarling@hey.com'; + SELECT 'from your loving sql server consultant, erik darling: https://erikdarling.com'; /*Parameters*/ SELECT @@ -203,6 +203,38 @@ BEGIN ap.parameter_id OPTION(RECOMPILE); + /* + License to F5 + */ + SELECT + mit_license_yo = + 'i am MIT licensed, so like, do whatever' + UNION ALL + + SELECT + mit_license_yo = + 'see printed messages for full license'; + + RAISERROR(' +MIT License + +Copyright 2026 Darling Data, LLC + +https://www.erikdarling.com/ + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, +sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +', 0, 1) WITH NOWAIT; + RETURN; END; @@ -530,11 +562,16 @@ BEGIN 7, @start_date ), + /* + Same seven days as @end_date above, mirroring sp_QuickieStore: + a different span here would record a window the queries did not + actually use + */ @end_date_original = DATEADD ( DAY, - 1, + 7, @start_date_original ); END; @@ -573,13 +610,19 @@ SELECT NULLIF(@query_text_search_not, ''); /* -Parse schema from procedure name if provided in schema.procedure format +Parse schema from procedure name if provided in schema.procedure format. +PARSENAME copes with bracketed ([dbo].[proc]) and unbracketed (dbo.proc) +alike; the old LIKE '[[]%].[[]%]' pattern only matched the fully bracketed +form, so an unbracketed dbo.proc fell through and was later QUOTENAME'd whole +into a single mangled [dbo.proc] identifier that could never resolve. +Only split when the caller didn't pass @procedure_schema separately, the name +has a schema part, and it's exactly two parts (part 3 NULL) so a stray +three-part name doesn't silently drop its database component here. */ -IF -( - @procedure_name LIKE N'[[]%].[[]%]' - AND @procedure_schema IS NULL -) +IF @procedure_name IS NOT NULL +AND @procedure_schema IS NULL +AND PARSENAME(@procedure_name, 2) IS NOT NULL +AND PARSENAME(@procedure_name, 3) IS NULL BEGIN SELECT @procedure_schema = PARSENAME(@procedure_name, 2), @@ -745,7 +788,7 @@ OPTION(RECOMPILE);' + @nc10; EXECUTE sys.sp_executesql @sql, N'@procedure_exists bit OUTPUT, - @procedure_name_quoted sysname', + @procedure_name_quoted nvarchar(1024)', /* nvarchar(1024), matching the variable's own declaration; binding it as sysname truncated the quoted 3-part name to 128 chars, so OBJECT_ID failed and a valid procedure was falsely rejected */ @procedure_exists OUTPUT, @procedure_name_quoted; @@ -1176,6 +1219,21 @@ CREATE TABLE constant_value nvarchar(max) NULL ); +/* +Landing table for the raw parameter attributes shredded straight out of each +plan's ParameterList. Reading them once here, then cleaning the values in a +second pass over these plain-string columns, avoids re-parsing the plan XML +several times per parameter (see the note at the parameter-extraction step). +*/ +CREATE TABLE + #parameter_shred +( + plan_id bigint NOT NULL, + param_column sysname NULL, + param_data_type sysname NULL, + param_compiled_value nvarchar(max) NULL +); + /* If @query_plan_xml was supplied, seed the repro pipeline with one synthetic plan so the parser, warnings, and repro-builder can all run without Query @@ -1204,6 +1262,20 @@ BEGIN N'nvarchar(max)' ); + /* + No StmtSimple node means this isn't a ShowPlanXML document we can build a + repro from (wrong XML entirely, or a plan shape with no simple statement, + e.g. cursor-only). Stop here rather than silently seeding a blank query and + handing the user an empty repro. + */ + IF @synthetic_query_text IS NULL + BEGIN + RAISERROR('The supplied @query_plan_xml has no StmtSimple statement text +Pass a valid ShowPlanXML document (the output of an actual/estimated execution plan for a single statement)', + 10, 1) WITH NOWAIT; + RETURN; + END; + INSERT #query_store_plan ( @@ -2116,6 +2188,12 @@ BEGIN SELECT @sql = @isolation_level; + /* + The shredded value must be qualified b.x, not x: inside the generated query, + a.x is the whole .. document and b.x is each per-node from the + CROSS APPLY. An unqualified x.value was ambiguous (error 209), so this ignore + list silently failed to compile and never filtered anything. + */ SELECT @sql += N' INSERT @@ -2131,7 +2209,7 @@ BEGIN ( SELECT plan_id = - x.value + b.x.value ( ''(./text())[1]'', ''bigint'' @@ -2175,6 +2253,10 @@ BEGIN SELECT @sql = @isolation_level; + /* + Same b.x qualification as the @ignore_plan_ids block above: b.x is the + per-node from the CROSS APPLY, and leaving it unqualified was error 209. + */ SELECT @sql += N' INSERT @@ -2190,7 +2272,7 @@ BEGIN ( SELECT query_id = - x.value + b.x.value ( ''(./text())[1]'', ''bigint'' @@ -4134,43 +4216,23 @@ SELECT @current_table = N'extracting parameters from query plans'; INSERT - #query_parameters + #parameter_shred WITH (TABLOCK) ( plan_id, - parameter_name, - parameter_data_type, - parameter_compiled_value + param_column, + param_data_type, + param_compiled_value ) SELECT qsp.plan_id, - parameter_name = - LTRIM(RTRIM(cr.c.value(N'@Column', N'sysname'))), - parameter_data_type = - LTRIM(RTRIM(cr.c.value(N'@ParameterDataType', N'sysname'))), - parameter_compiled_value = - CASE - /*Strip parentheses from values like (13)*/ - WHEN cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)') LIKE N'(%)' - THEN SUBSTRING - ( - cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)'), - 2, - LEN(cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)')) - 2 - ) - /*Strip guid wrapper from values like {guid'...'}*/ - WHEN cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)') LIKE N'{guid''%''}' - THEN N'''' + - SUBSTRING - ( - cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)'), - 7, - LEN(cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)')) - 8 - ) + - N'''' - ELSE cr.c.value(N'@ParameterCompiledValue', N'nvarchar(MAX)') - END + param_column = + cr.c.value(N'@Column', N'sysname'), + param_data_type = + cr.c.value(N'@ParameterDataType', N'sysname'), + param_compiled_value = + cr.c.value(N'@ParameterCompiledValue', N'nvarchar(max)') FROM #query_store_plan AS qsp CROSS APPLY ( @@ -4201,6 +4263,59 @@ WHERE CHARINDEX(N'', qsp.query_plan) > 0 AND x.parameter_list_xml IS NOT NULL OPTION(RECOMPILE); +/* +Clean the parameter values in a second pass over the materialized columns. + +The earlier version read cr.c.value(N'@ParameterCompiledValue') four times in +this one statement, plus @Column and @ParameterDataType - nine .value() calls on +the same node. Against untyped XML the optimizer builds a separate XML Reader +table-valued function per call, ten of them stacked in nested-loop joins, and +each one re-parses the plan from scratch. That made this the procedure's single +largest cost on many-plan workloads (measured 13x slower on 500 plans than the +shred-once form below). Reading each attribute once into #parameter_shred and +applying the cleanup here on plain strings produces identical values. +*/ +INSERT + #query_parameters +WITH + (TABLOCK) +( + plan_id, + parameter_name, + parameter_data_type, + parameter_compiled_value +) +SELECT + ps.plan_id, + parameter_name = + LTRIM(RTRIM(ps.param_column)), + parameter_data_type = + LTRIM(RTRIM(ps.param_data_type)), + parameter_compiled_value = + CASE + /*Strip parentheses from values like (13)*/ + WHEN ps.param_compiled_value LIKE N'(%)' + THEN SUBSTRING + ( + ps.param_compiled_value, + 2, + LEN(ps.param_compiled_value) - 2 + ) + /*Strip guid wrapper from values like {guid'...'}*/ + WHEN ps.param_compiled_value LIKE N'{guid''%''}' + THEN N'''' + + SUBSTRING + ( + ps.param_compiled_value, + 7, + LEN(ps.param_compiled_value) - 8 + ) + + N'''' + ELSE ps.param_compiled_value + END +FROM #parameter_shred AS ps +OPTION(RECOMPILE); + /* Fill in parameters declared in query text but missing from the plan's ParameterList. @@ -4285,8 +4400,18 @@ CROSS APPLY param_xml = TRY_CAST ( + /* + Split on ',@' (the parameter boundary), not every comma. + A scaled type carries its own comma (numeric(10,2), + decimal(38,6)), always followed by a digit; the separator + between parameters is always a comma immediately followed by + the next parameter's @name. Splitting on every comma tore + numeric(10,2) in half, and the malformed fragment blew up + LEFT with error 537, aborting the whole procedure for any + query with a scaled-type parameter. + */ N'

' + - REPLACE(prefix.param_prefix, N',', N'

') + + REPLACE(REPLACE(prefix.param_prefix, N', @', N',@'), N',@', N'

@') + N'

' AS xml ) ) AS px @@ -4865,6 +4990,18 @@ WHERE NOT EXISTS OPTION(RECOMPILE); +/* +Match sp_QuickieStore: when nothing landed in the search window there is no +repro to build, so tell the caller which table came back empty instead of +returning a silent, unexplained empty result set. +*/ +IF EXISTS +( + SELECT + 1/0 + FROM #repro_queries AS rq +) +BEGIN SELECT table_name = N'results', @@ -5055,6 +5192,13 @@ OUTER APPLY ORDER BY qsrs.last_execution_time DESC OPTION(RECOMPILE); +END; +ELSE +BEGIN + SELECT + result = + N'#repro_queries is empty'; +END; END TRY @@ -5070,11 +5214,16 @@ BEGIN CATCH RAISERROR('%s', 10, 1, @sql) WITH NOWAIT; END; - IF @@TRANCOUNT > 0 - BEGIN - ROLLBACK; - END; - + /* + No ROLLBACK here on purpose. This procedure only reads Query Store and builds + strings - it opens no transaction of its own (there is no BEGIN TRANSACTION + anywhere in it), so a ROLLBACK could only ever unwind the CALLER's + transaction. That is the standard error-handling template misapplied to a + read-only procedure: on any internal error it would silently destroy work the + caller was in the middle of. It also broke INSERT ... EXECUTE against this + proc, where the ROLLBACK raised Msg 3915 instead of surfacing the real error. + sp_QuickieStore, which this was built from, carries no ROLLBACK either. + */ THROW; END CATCH; @@ -5092,6 +5241,15 @@ BEGIN qsrs.plan_id OPTION(RECOMPILE); + SELECT + table_name = + N'#parameter_shred', + ps.* + FROM #parameter_shred AS ps + ORDER BY + ps.plan_id + OPTION(RECOMPILE); + SELECT table_name = N'#query_store_plan', diff --git a/sp_QueryReproBuilder/tests/README.md b/sp_QueryReproBuilder/tests/README.md new file mode 100644 index 00000000..e2da88e5 --- /dev/null +++ b/sp_QueryReproBuilder/tests/README.md @@ -0,0 +1,145 @@ +# sp_QueryReproBuilder Tests + +`sp_QueryReproBuilder` reads a query plan and emits a **runnable** reproduction +script -- the T-SQL in `#repro_queries.executable_query`, surfaced in the primary +result set keyed `table_name = 'results'`. The generated repro is the whole +product. Compiling the procedure proves nothing about it; a repro that reads +correctly but will not run is worse than useless, because someone will paste it +into a window expecting it to work. + +So the suite here does not stop at "a repro came back." It feeds a plan in, +pulls the emitted repro out, and **actually executes it**, because many of the +ways this generator can go wrong are semantic, not syntactic, and only surface +when the script runs. + +**Run the suite before and after any change to `sp_QueryReproBuilder.sql`.** + +``` +cd sp_QueryReproBuilder/tests +python run_tests.py --server SQL2022 +python mutation_check.py --server SQL2022 +``` + +Both take `--server` and `--password` (default `SQL2022` / the standard local sa +password). Expect `237 passed, 0 failed` and `5 of 5 mutations caught`. + +## What is here + +| File | What it does | +| --- | --- | +| `run_tests.py` | 44 cases, 237 assertions. Each embeds a ShowPlanXML, drives it through `@query_plan_xml`, extracts the emitted repro, executes it, and asserts it built, is correct, and RAN. Self-contained and self-cleaning. | +| `mutation_check.py` | Plants five plausible generation bugs in a scratch copy of the procedure, installs each, and asserts `run_tests.py` goes RED on every one -- proof the suite has teeth. Restores the real build afterward. | +| `template_generate.sql` | The generation half: runs the procedure in `@query_plan_xml` mode and lets its result set print so the repro can be read off stdout. Driven by `run_tests.py`. | +| `template_execute.sql` | The execution half: takes the repro back (as base64) into a real `nvarchar(max)` variable and runs it with `sys.sp_executesql` inside `BEGIN TRANSACTION ... ROLLBACK` and `TRY/CATCH`. Driven by `run_tests.py`. | + +Everything is embedded or synthesized. There is no dependency on a captured plan +cache, on `StackOverflow2013`, or on any particular user database. Plans +reference `sys` objects, which are always present; the single case that needs a +real user table (a parameterized `UPDATE`) uses a small fixture the harness +creates in `tempdb` and drops on the way out. + +## The execute check is the one that earns its keep + +Every generated repro is run for real, each in its own rolled-back transaction, +via `sys.sp_executesql` on the repro held in a variable. Three things about that +are deliberate: + +- **It runs, it does not just parse.** `SET PARSEONLY ON` would sail past a repro + that parses but cannot bind -- a parameter declared with the wrong type, a + value that will not convert. Those are the characteristic bugs of a generator + like this, and only running the script catches them. +- **The repro lives in a variable, executed with `sp_executesql`.** That defers + its compile, so a broken repro (say, an unbalanced quote from a botched + apostrophe-doubling change) is a *catchable* error reported as + `EXEC_RESULT: FAIL`, not a batch-killing parse error that takes the harness + down with it. Pasting the repro verbatim into a batch would misclassify that + same bug as an uncatchable syntax error of a different severity. +- **The hand-off is base64.** `run_tests.py` passes the repro back as base64 of + its utf-16-le bytes, so there is no quote-escaping to get wrong and no + 8000-character string-literal limit to trip over on the long-repro cases. + +The generation half deliberately does **not** use `INSERT ... EXECUTE`. +`sp_QueryReproBuilder` returns different result-set shapes -- the wide `results` +set when a repro is built versus the single-column `#repro_queries is empty` +diagnostic when nothing lands -- and a fixed-shape `INSERT ... EXECUTE` target +cannot absorb both. The repro is read off stdout instead, from the +`executable_query` processing instruction that renders as ``. + +## Coverage + +The battery carries over the assertions that matter and holds each generated +repro to both "is it correct?" and "does it run?": + +- **Parameterized plans** -- explicit `sp_executesql`-style parameters, and the + many-parameter (`@1..@12`, 399-parameter) cases. +- **No-parameter plans.** +- **Scaled / precision types**, each with the *authentic* `ParameterCompiledValue` + serialization captured from real sniffed plans: `numeric(38,2)` as `(12.50)`, + `decimal(38,10)`, `money` as `($99.9500)`, `float`/`real` in scientific + notation, `varchar(8000)`, `nvarchar(max)`, `datetime2(7)`, + `datetimeoffset(7)` (note the ` +05:30` with its leading space), `datetime`, + `date`, `time(7)`, `uniqueidentifier` as `{guid'...'}`, `varbinary(8)`, `bit`, + `bigint`. +- **Entity characters** (`&` `<` `>` `'`) in statement text and in a parameter's + compiled value. +- **Apostrophes** in both a parameterized statement (must be re-doubled inside + the outer `N'...'`) and a raw statement. +- **Unicode / N-literals** (round-tripped through the repro, not just declared). +- **Long statement text** both `> 4000` and `> 8000` characters, plus a long + parameterized statement `> 8000`. +- **Parameter/value alignment** -- declaration order versus ParameterList order, + asserting value *i* binds to declaration *i*. +- **Control characters** (CR, LF) embedded in a parameter's sniffed value. +- **Echo cases** that execute the repro and read back the *actually-bound* + values, catching a silent reorder or misbind that still runs. + +### The documented `?` fill-in behavior + +When a parameter is declared in the query text but is **absent from the plan's +ParameterList**, the procedure cannot know its value. It does the safe thing: it +sets the value to `?` and emits a warning ("... were not found in the plan +ParameterList ..."). The repro then fails **loud** (`Msg 102` near `?`) if run +as-is, rather than silently executing with a wrong value. + +The suite asserts exactly that contract: the warning fires, the `?` placeholder +is present, and execution does **not** silently pass. This is the intended +behavior, not a bug -- you are meant to fill in the value before running. The +`mutation_check.py` `M6` mutation (making that path emit `NULL` instead of `?`) +proves the assertion has teeth: it would let the repro run silently with a wrong +value, and the suite catches it. + +The same safe degradation covers the **first-ParameterList-wins** hazard: when a +decoy `ParameterList` (as XML-reader / UDF operators emit) precedes the query's +own, the procedure's substring extraction grabs the decoy, the real parameter is +lost, and the case falls back to the warned `?` path -- loud failure, not a +silent wrong result. That is asserted (`decoy:decoy_first_degrades_loud`) rather +than papered over. + +## mutation_check.py -- proving the teeth + +A green suite is only worth trusting if it goes red when the generator breaks. +`mutation_check.py` plants five bugs, one at a time, in a scratch copy of the +procedure (it **never** edits the repo file), installs each mutant, runs +`run_tests.py`, and asserts it goes red: + +| Mutation | Broken behavior | Caught by | +| --- | --- | --- | +| `M1` | value list re-sorted independently of the declaration list | echo cases (wrong bound values / bind error) | +| `M2` | every parameter's declared type forced to `int` | type round-trip assertions **and** execution (numeric/date/binary values will not bind to `int`) | +| `M3` | fill-in path splits scaled types like `numeric(10,2)` on the internal comma | the `?` scaled-fill-in case (`numeric(10,2)` no longer intact in the declaration) | +| `M4` | statement-apostrophe doubling removed | execution (broken outer `N'...'` literal) | +| `M6` | `?` fill-in emitted as `NULL` | the `?`-path assertions (silent run instead of loud fail) | + +`M4` and `M1` are caught **only** because the repros are executed -- there is no +static check that would notice them -- which is the whole argument for the +execute check. After the run, `mutation_check.py` restores the real build from +`sp_QueryReproBuilder.sql` and leaves it installed. Run `--dry` to verify the +mutation patterns still anchor to the current procedure (each must match exactly +once) after editing it. + +## Not wired into CI + +Like the `sp_IndexCleanup` suite, these run by hand against a live instance. +They install the procedure and execute generated SQL, so they need a real SQL +Server, not a syntax check. Run them yourself before shipping a change to +`sp_QueryReproBuilder.sql`. diff --git a/sp_QueryReproBuilder/tests/mutation_check.py b/sp_QueryReproBuilder/tests/mutation_check.py new file mode 100644 index 00000000..eeec0975 --- /dev/null +++ b/sp_QueryReproBuilder/tests/mutation_check.py @@ -0,0 +1,211 @@ +""" +sp_QueryReproBuilder harness mutation check +=========================================== +Proves run_tests.py has teeth. A green test suite is only worth trusting if it +goes RED when the thing it tests is broken, so this planted-bug check: + + 1. reads the REAL procedure from the repo (never edits that file), + 2. applies each plausible generation bug to a scratch copy, + 3. installs the mutant into master on the target, + 4. runs run_tests.py and asserts it goes RED (the mutation is CAUGHT), then + 5. restores the real build from the repo file and leaves it installed. + +The mutations mirror the three failure modes the task calls out plus one more: + M1 - value list re-sorted independently of the declaration list + (silent parameter/value misbinding) + M2 - every parameter's declared type forced to int + M4 - statement-apostrophe doubling removed (broken outer N'...' literal) + M6 - the '?' fill-in placeholder emitted as NULL (would silently run with a + wrong value instead of failing loud) + +Usage: + python mutation_check.py [--server SQL2022] [--password L!nt0044] [--dry] + +--dry only verifies each pattern still matches the current procedure exactly +once (run it after editing sp_QueryReproBuilder.sql to catch anchor drift). +Exits 1 if any mutation SURVIVES (a coverage hole) or the real build cannot be +restored. +""" + +import argparse +import atexit +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment + so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to + 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the + go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + container's self-signed cert and disable encryption, which the modern Go + TLS stack needs to connect to the SQL Server 2017 container.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_PROC = os.path.normpath(os.path.join(HERE, "..", "sp_QueryReproBuilder.sql")) +RUN_TESTS = os.path.join(HERE, "run_tests.py") + +WORKDIR = tempfile.mkdtemp(prefix="qrb_mut_") +atexit.register(lambda: shutil.rmtree(WORKDIR, ignore_errors=True)) + +# Each mutation: (name, pattern, replacement, why it must be caught). +# Every pattern must match the current procedure exactly once; --dry checks that. +MUTATIONS = [ + ("M1_value_list_misalign", + r"(ISNULL\s*\(\s*qp\.parameter_compiled_value,\s*N'NULL'\s*\)\s*" + r"FROM #query_parameters AS qp\s*WHERE qp\.plan_id = qsp\.plan_id\s*ORDER BY\s*)" + r"qp\.parameter_name", + r"\1qp.parameter_compiled_value DESC", + "value list re-sorted independently of the decl list -> silent misbinding"), + + ("M2_decl_type_forced_int", + r"qp\.parameter_name\s*\+\s*N' '\s*\+\s*qp\.parameter_data_type", + r"qp.parameter_name + N' ' + N'int'", + "every parameter declared int -> wrong type for numeric/nvarchar/etc."), + + ("M4_no_stmt_apostrophe_doubling", + r"REPLACE\s*\(\s*clean_query\.query_text_cleaned,\s*N'''',\s*N''''''\s*\)", + r"clean_query.query_text_cleaned", + "statement apostrophes not doubled -> broken outer N'...' literal"), + + ("M6_qmark_becomes_null", + r"(parameter_compiled_value =\s*)N'\?'(\s*FROM #query_store_plan)", + r"\1N'NULL'\2", + "missing params default to NULL and silently execute instead of ?"), + + ("M3_scaled_type_split", + r"REPLACE\(prefix\.param_prefix, N',@', N'

@'\)", + r"REPLACE(prefix.param_prefix, N',', N'

')", + "fill-in path splits scaled types like numeric(10,2) on the internal comma"), +] + + +def read_proc(): + with open(REPO_PROC, "r", encoding="utf-8-sig") as f: + return f.read() + + +def pattern_check(base): + print("Pattern match check (each must match exactly once):") + all_ok = True + for (name, pat, _repl, _why) in MUTATIONS: + n = len(re.findall(pat, base, re.DOTALL)) + ok = n == 1 + all_ok = all_ok and ok + print(" %-32s matches=%d %s" % (name, n, "OK" if ok else "!! DRIFTED")) + return all_ok + + +def install(path, server, password, label): + r = subprocess.run( + [*_sqlcmd_prefix(), "-S", server, "-U", "sa", "-P", password, + "-d", "master", "-i", path, "-b"], + capture_output=True, text=True, timeout=180) + if r.returncode != 0: + print(" install(%s) FAILED rc=%d: %s" + % (label, r.returncode, (r.stdout + r.stderr).strip()[:300])) + return False + return True + + +def run_harness(server, password): + """Run run_tests.py; return (failed_count, ok) where ok is False if the + run could not be parsed.""" + r = subprocess.run( + [sys.executable, RUN_TESTS, "--server", server, "--password", password], + capture_output=True, text=True, timeout=900) + m = re.search(r"Results:\s*(\d+) passed,\s*(\d+) failed", r.stdout) + if not m: + return None, r.stdout[-800:] + return int(m.group(2)), None + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server", default="SQL2022") + ap.add_argument("--password", default="L!nt0044") + ap.add_argument("--dry", action="store_true") + args = ap.parse_args() + + base = read_proc() + + all_ok = pattern_check(base) + if args.dry: + sys.exit(0 if all_ok else 1) + if not all_ok: + print("\nAborting: a mutation pattern no longer matches the procedure " + "exactly once. Fix the anchors before trusting this check.") + sys.exit(1) + + mutant_path = os.path.join(WORKDIR, "_mutant.sql") + results = [] + baseline_failed = None + + try: + print("\nInstalling the real build (baseline)...") + if not install(REPO_PROC, args.server, args.password, "repo"): + print("Could not install the real build; aborting.") + sys.exit(1) + baseline_failed, err = run_harness(args.server, args.password) + if baseline_failed is None: + print("Could not parse baseline harness output:\n" + (err or "")) + sys.exit(1) + print("Baseline: %d failed (expected 0)." % baseline_failed) + if baseline_failed != 0: + print("Baseline is not green; fix run_tests.py before mutating.") + sys.exit(1) + + print("\nApplying mutations:") + for (name, pat, repl, why) in MUTATIONS: + mutant = re.sub(pat, repl, base, count=1, flags=re.DOTALL) + if mutant == base: + results.append((name, "NOT_APPLIED", why)) + print(" %-32s NOT APPLIED (pattern did not change anything)" % name) + continue + with open(mutant_path, "w", encoding="utf-8") as f: + f.write(mutant) + if not install(mutant_path, args.server, args.password, name): + results.append((name, "INSTALL_FAIL", why)) + continue + failed, err = run_harness(args.server, args.password) + if failed is None: + results.append((name, "HARNESS_UNPARSED", why)) + print(" %-32s harness output unparsed:\n%s" % (name, err or "")) + continue + caught = failed > baseline_failed + results.append((name, "CAUGHT" if caught else "SURVIVED", why)) + print(" %-32s -> %s (%d failed)" + % (name, "CAUGHT" if caught else "SURVIVED !!", failed)) + finally: + print("\nRestoring the real build from the repo...") + restored = install(REPO_PROC, args.server, args.password, "repo-restore") + if not restored: + print("!! FAILED to restore the real build. Reinstall manually:") + print(" sqlcmd -S %s -U sa -P *** -d master -i \"%s\" -b" + % (args.server, REPO_PROC)) + + print("\n==== MUTATION SUMMARY ====") + for (name, verdict, why) in results: + print(" %-32s %s" % (name, verdict)) + print(" (%s)" % why) + + survived = [n for (n, v, _w) in results if v != "CAUGHT"] + caught = sum(1 for (_n, v, _w) in results if v == "CAUGHT") + print("\n%d of %d mutations caught." % (caught, len(results))) + if survived: + print("SURVIVED / not caught: " + ", ".join(survived)) + print("A surviving mutation is a hole in run_tests.py.") + sys.exit(1) + print("All planted bugs were caught: the harness has teeth.") + + +if __name__ == "__main__": + main() diff --git a/sp_QueryReproBuilder/tests/run_tests.py b/sp_QueryReproBuilder/tests/run_tests.py new file mode 100644 index 00000000..36b66c83 --- /dev/null +++ b/sp_QueryReproBuilder/tests/run_tests.py @@ -0,0 +1,824 @@ +""" +sp_QueryReproBuilder generate-and-execute test harness +====================================================== +sp_QueryReproBuilder reads a query plan and emits a RUNNABLE reproduction script +(the T-SQL in #repro_queries.executable_query, surfaced in the primary result set +keyed table_name = 'results'). @query_plan_xml = bypasses Query +Store and is the deterministic entry point this harness drives. + +For every case the harness: + 1. GENERATES - feeds an embedded ShowPlanXML through @query_plan_xml and pulls + the emitted repro out of the executable_query processing instruction + (template_generate.sql; extracted from stdout, never INSERT ... EXECUTE). + 2. EXECUTES - hands the repro back and runs it for real with sys.sp_executesql + inside BEGIN TRANSACTION ... ROLLBACK / TRY-CATCH (template_execute.sql). + Compiling is not enough: many bugs in a generated repro are semantic and + only surface when the script actually runs. + 3. ASSERTS - the repro built, is correct (param count/types/values and + statement text preserved), and RAN. + +Every plan is embedded as a string constant, so the suite is fully portable and +deterministic - it depends on no captured plan cache and no specific user +database. Plans reference sys objects (always present); the one case that needs +a real user table uses a small fixture the harness creates in tempdb and drops. + +The authentic ParameterCompiledValue serializations used below (e.g. numeric as +(12.50), money as ($99.9500), datetimeoffset as '... +05:30', guid as +{guid'...'}) were captured from real sniffed plans on SQL Server, so the +embedded ParameterList entries match what the procedure sees in the wild. + +Usage: + python run_tests.py [--server SQL2022] [--password L!nt0044] [--only SUBSTR] [--verbose] + +Exits 1 if any assertion fails. +""" + +import argparse +import atexit +import base64 +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +GEN_TEMPLATE = os.path.join(HERE, "template_generate.sql") + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment + so the harness runs both locally and in CI. Locally SQLCMD_BIN defaults to + 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the + go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + container's self-signed cert and disable encryption, which the modern Go + TLS stack needs to connect to the SQL Server 2017 container.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) +EXEC_TEMPLATE = os.path.join(HERE, "template_execute.sql") + +# Per-case .sql files are ephemeral; keep them out of the committed tests dir. +WORKDIR = tempfile.mkdtemp(prefix="qrb_tests_") +atexit.register(lambda: shutil.rmtree(WORKDIR, ignore_errors=True)) +NS = "http://schemas.microsoft.com/sqlserver/2004/07/showplan" +FIXTURE = "tempdb.dbo.qrb_repro_fixture" + +# Authentic ParameterCompiledValue serializations captured from real sniffed +# plans on SQL Server. Kept as constants so the embedded plans stay honest. +CV = { + "int_5": "(5)", + "bigint": "(123456789)", + "numeric_38_2": "(12.50)", + "decimal_38_10": "(3.1415926535)", + "money": "($99.9500)", + "float": "(2.2500000000000000e+000)", + "real": "(1.5000000000000000e+000)", + "bit": "(1)", + "datetime": "'2020-01-15 10:20:30.123'", + "datetime2_7": "'2020-01-15 10:20:30.1234567'", + "datetimeoffset": "'2020-01-15 10:20:30.1234567 +05:30'", + "date": "'2020-01-15'", + "time_7": "'10:20:30.1234567'", + "guid": "{guid'6F9619FF-8B86-D011-B42D-00C04FC964FF'}", + "varchar_8000": "'Autobiographer'", + "nvarchar_40": "N'Community'", + "varbinary_8": "0x0102030405060708", +} + +# Warning text the procedure emits when a parameter is declared in the query +# text but absent from the plan ParameterList (the documented '?' fill-in path). +QMARK_WARNING = "were not found in the plan ParameterList" + + +# ------------------------------------------------------------------ plan builder + +def xesc(s): + """Escape a string for an XML double-quoted attribute value. + + CR/LF/TAB become numeric character references: a literal CR/TAB in an + attribute is normalized by the XML parser, so to make the procedure actually + see a carriage return in a compiled value (as real plans store it, via + ) we emit etc. + """ + return (s.replace("&", "&").replace("<", "<") + .replace(">", ">").replace('"', """) + .replace("\r", " ").replace("\n", " ").replace("\t", " ")) + + +def make_plan(stmt_text, params=None, body_extra="", statements_extra="", body_pre=""): + """Build a minimal well-formed ShowPlanXML the procedure can parse. + + stmt_text: StatementText. For a parameterized repro it must begin with the + '(@name type, ...)' prefix exactly as SQL Server stores it; the procedure + strips that prefix and rebuilds the declaration list from the + ParameterList (not from the text). + params: list of (column, datatype, compiled_value) -> a ParameterList. Pass + None to omit the ParameterList entirely (drives the '?' fill-in path when + the text still declares parameters). + body_pre: raw XML injected BEFORE the query's ParameterList (e.g. a decoy + ParameterList, to probe the first-ParameterList-wins extraction). + """ + plist = "" + if params is not None: + rows = "".join( + ''.format(xesc(c), xesc(dt), xesc(cv)) + for (c, dt, cv) in params + ) + plist = "{}".format(rows) + return ( + '' + '' + '' + '' + '' + '{bp}{pl}{be}' + '' + '' + '{se}' + '' + '' + ).format(ns=NS, st=xesc(stmt_text), pl=plist, be=body_extra, se=statements_extra, bp=body_pre) + + +# An internal-operator ParameterList (nested ColumnReference, no +# ParameterDataType), like the ones XML-reader / UDF operators emit. It +# serializes as too, which is what makes it a decoy for the +# procedure's substring-based first-ParameterList extraction. +DECOY_PLIST = ('' + '' + '') + + +# ------------------------------------------------------------------ sqlcmd plumbing + +def _write_sql(path, sql): + """Write a case file as UTF-8 with BOM so go-sqlcmd reads any N'...' unicode + correctly.""" + with open(path, "w", encoding="utf-8-sig") as f: + f.write(sql) + + +def _run_sql_file(server, password, path): + cmd = [ + *_sqlcmd_prefix(), + "-S", server, "-U", "sa", "-P", password, + "-d", "master", "-i", path, "-y", "0", "-h", "-1", + ] + # Capture bytes and decode as UTF-8: go-sqlcmd emits UTF-8 on stdout, so a + # unicode repro survives the round trip (text=True would use the console + # code page on Windows and mangle it). + r = subprocess.run(cmd, capture_output=True, timeout=180) + out = (r.stdout or b"").decode("utf-8", errors="replace") + err = (r.stderr or b"").decode("utf-8", errors="replace") + return out + "\n" + err + + +def find_sql_errors(text): + """Return SQL errors of severity 16 or higher. go-sqlcmd reports errors on + stdout, so callers pass the combined stream. Matching severity numerically + catches Level 16 through 19 rather than only the literal 'Level 16'.""" + if not text: + return [] + return re.findall(r"Msg \d+, Level 1[6-9][^\n]*", text) + + +def generate(server, password, plan_text): + """Run one plan through the procedure and pull the emitted repro out of the + executable_query processing instruction on stdout.""" + with open(GEN_TEMPLATE, "r", encoding="utf-8") as f: + tmpl = f.read() + sql = tmpl.replace("@@PLAN@@", plan_text.replace("'", "''")) + path = os.path.join(WORKDIR, "_gen_case.sql") + _write_sql(path, sql) + out = _run_sql_file(server, password, path) + + res = {"raw": out, "repro": None, "proc_error": None, "no_repro": False, + "sql_errors": find_sql_errors(out)} + + m = re.search(r"PROC_ERROR:\s*(.+)", out) + if m: + res["proc_error"] = m.group(1).strip() + + # The repro renders as a processing instruction: . Its + # content is emitted raw (entities decoded), so it is extracted verbatim. + m = re.search(r"<\?_(.*?)\?>", out, re.DOTALL) + if m: + res["repro"] = m.group(1).strip("\r\n").lstrip() + + if res["repro"] is None: + res["no_repro"] = True + return res + + +def _b64_assignments(repro): + b = base64.b64encode(repro.encode("utf-16-le")).decode("ascii") + chunks = [b[i:i + 7000] for i in range(0, len(b), 7000)] + return "\n".join("SET @b64 = @b64 + '%s';" % c for c in chunks) + + +RUNNER_PLAIN = """\ +BEGIN TRANSACTION; +BEGIN TRY + EXECUTE sys.sp_executesql @repro; + PRINT 'EXEC_RESULT: PASS'; +END TRY +BEGIN CATCH + PRINT 'EXEC_RESULT: FAIL Msg ' + CONVERT(varchar(20), ERROR_NUMBER()) + + ' Lvl ' + CONVERT(varchar(20), ERROR_SEVERITY()) + + ' : ' + LEFT(ERROR_MESSAGE(), 300); +END CATCH; +IF XACT_STATE() <> 0 + ROLLBACK TRANSACTION; +""" + +RUNNER_ECHO = """\ +CREATE TABLE #echo (col_a int NULL, col_s nvarchar(100) NULL, col_b int NULL); +BEGIN TRANSACTION; +BEGIN TRY + INSERT #echo + EXECUTE sys.sp_executesql @repro; + PRINT 'EXEC_RESULT: PASS'; + SELECT echo_line = + 'ECHO: a=[' + ISNULL(CONVERT(varchar(20), e.col_a), '') + + '] s=[' + ISNULL(e.col_s, '') + + '] b=[' + ISNULL(CONVERT(varchar(20), e.col_b), '') + ']' + FROM #echo AS e; +END TRY +BEGIN CATCH + PRINT 'EXEC_RESULT: FAIL Msg ' + CONVERT(varchar(20), ERROR_NUMBER()) + + ' Lvl ' + CONVERT(varchar(20), ERROR_SEVERITY()) + + ' : ' + LEFT(ERROR_MESSAGE(), 300); +END CATCH; +IF XACT_STATE() <> 0 + ROLLBACK TRANSACTION; +""" + + +def execute(server, password, repro, echo=False): + """Execute a repro for real, inside a rolled-back transaction. Returns the + EXEC_RESULT verdict and (for echo cases) the actually-bound values.""" + with open(EXEC_TEMPLATE, "r", encoding="utf-8") as f: + tmpl = f.read() + sql = (tmpl.replace("@@B64_ASSIGNMENTS@@", _b64_assignments(repro)) + .replace("@@RUNNER@@", RUNNER_ECHO if echo else RUNNER_PLAIN)) + path = os.path.join(WORKDIR, "_exec_case.sql") + _write_sql(path, sql) + out = _run_sql_file(server, password, path) + + res = {"raw": out, "exec_result": None, "echo": None, + "sql_errors": find_sql_errors(out)} + m = re.search(r"EXEC_RESULT:\s*(PASS|FAIL[^\n]*)", out) + if m: + res["exec_result"] = "PASS" if m.group(1) == "PASS" else m.group(1).strip() + m = re.search(r"ECHO: a=\[(.*?)\] s=\[(.*?)\] b=\[(.*?)\]", out) + if m: + res["echo"] = {"a": m.group(1), "s": m.group(2), "b": m.group(3)} + return res + + +# ------------------------------------------------------------------ repro parsing + +def sql_literal_segments(text): + """Yield (start, end, content) for each N'...'-quoted literal in order, with + '' collapsed back to a single quote in content.""" + segs = [] + i = 0 + n = len(text) + while i < n: + q = text.find("'", i) + if q < 0: + break + j = q + 1 + buf = [] + while j < n: + if text[j] == "'": + if j + 1 < n and text[j + 1] == "'": + buf.append("'") + j += 2 + continue + break + buf.append(text[j]) + j += 1 + segs.append((q, j, "".join(buf))) + i = j + 1 + return segs + + +def split_top_values(s): + """Split an sp_executesql trailing value list on top-level commas (commas + that are not inside a string literal).""" + vals = [] + cur = [] + i = 0 + n = len(s) + in_str = False + while i < n: + ch = s[i] + if ch == "'": + in_str = not in_str + cur.append(ch) + i += 1 + continue + if not in_str and ch == ",": + vals.append("".join(cur).strip()) + cur = [] + i += 1 + while i < n and s[i] == " ": + i += 1 + continue + cur.append(ch) + i += 1 + last = "".join(cur).strip() + if last: + vals.append(last) + return vals + + +# ------------------------------------------------------------------ test battery + +def build_cases(): + cases = [] + + def add(name, plan, checks, echo=False, note=""): + cases.append({"name": name, "plan": plan, "checks": checks, + "echo": echo, "note": note}) + + # ---------- Parameterized, scaled/precision and edge types -------------- + # Each embeds the authentic ParameterCompiledValue for its type and asserts + # the declared type round-trips into the sp_executesql declaration list and + # the repro RUNS. This is the core of the required coverage: numeric(38,2), + # decimal, varchar(8000), nvarchar(max), datetime2, datetimeoffset, plus a + # broad type sweep. + typed = [ + ("p_int", "@p", "int", CV["int_5"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id = @p", ["5"]), + ("p_bigint", "@p", "bigint", CV["bigint"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.object_id = @p", ["123456789"]), + ("p_numeric_38_2", "@amt", "numeric(38,2)", CV["numeric_38_2"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @amt", ["12.50"]), + ("p_decimal_38_10", "@d", "decimal(38,10)", CV["decimal_38_10"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @d", ["3.1415926535"]), + ("p_money", "@m", "money", CV["money"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @m", ["$99.9500"]), + ("p_float", "@f", "float", CV["float"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @f", None), + ("p_real", "@r", "real", CV["real"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id > @r", None), + ("p_bit", "@b", "bit", CV["bit"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.is_nullable = @b", None), + ("p_datetime", "@d", "datetime", CV["datetime"], + "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.modify_date > @d", None), + ("p_datetime2_7", "@d", "datetime2(7)", CV["datetime2_7"], + "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.modify_date > @d", None), + ("p_datetimeoffset", "@d", "datetimeoffset(7)", CV["datetimeoffset"], + "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.modify_date > @d", None), + ("p_date", "@d", "date", CV["date"], + "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE CONVERT(date, o.modify_date) > @d", None), + ("p_time_7", "@t", "time(7)", CV["time_7"], + "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE CONVERT(time(7), o.modify_date) > @t", None), + ("p_uniqueidentifier", "@g", "uniqueidentifier", CV["guid"], + "SELECT c = COUNT_BIG(*) FROM sys.objects AS o WHERE o.object_id > 0 AND @g IS NOT NULL", None), + ("p_varchar_8000", "@nm", "varchar(8000)", CV["varchar_8000"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = @nm", ["'Autobiographer'"]), + ("p_nvarchar_40", "@nm", "nvarchar(40)", CV["nvarchar_40"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = @nm", ["N'Community'"]), + ("p_nvarchar_max", "@b", "nvarchar(max)", "N'hello'", + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE CONVERT(nvarchar(max), ac.name) = @b", ["N'hello'"]), + ("p_varbinary_8", "@vb", "varbinary(8)", CV["varbinary_8"], + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE @vb IS NOT NULL AND ac.column_id > 0", None), + ] + for (name, pn, dt, cv, body, present) in typed: + stmt = "(%s %s)%s" % (pn, dt, body) + add("type:%s" % name, + make_plan(stmt, params=[(pn, dt, cv)]), + {"must_exec": True, "expect_params": [(pn, dt)], + "values_present": present}) + + # ---------- No-parameter plans ---------------------------------------- + add("noparam:sys", + make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_objects AS o WHERE o.object_id > 0"), + {"must_exec": True}) + + # ---------- Long statement text: > 4000 and > 8000 -------------------- + pred_5k = " OR ".join("o.object_id = %d" % k for k in range(0, 300)) # ~ 5k chars + add("long:stmt_gt4000", + make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_objects AS o WHERE " + pred_5k), + {"must_exec": True, "min_repro_len": 4000}) + + pred_9k = " OR ".join("o.object_id = %d" % k for k in range(0, 900)) # ~ 16k chars + add("long:stmt_gt8000", + make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_objects AS o WHERE " + pred_9k), + {"must_exec": True, "min_repro_len": 8000}) + + long_in = ",".join("@p%d" % k for k in range(1, 400)) + long_decls = ",".join("@p%d int" % k for k in range(1, 400)) + add("long:param_stmt_gt8000", + make_plan("(%s)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id IN (%s)" + % (long_decls, long_in), + params=[("@p%d" % k, "int", "(%d)" % k) for k in range(1, 400)]), + {"must_exec": True, "min_repro_len": 8000}) + + # ---------- Entity characters (& < > ') in statement text ------------- + add("entity:literal_lt_gt_amp", + make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = N'a&c'"), + {"must_exec": True, "stmt_contains": "a&c"}) + + add("entity:gt_lt_in_query", + make_plan("SELECT x = (SELECT 1 WHERE 3 < 5 AND 5 > 3)"), + {"must_exec": True}) + + # Entity characters in a parameter's compiled value (authentic plans double + # the quote inside the attribute: ParameterCompiledValue="N'O''Brien & '"). + add("entity:param_value_quote_entity", + make_plan("(@n nvarchar(40))SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = @n", + params=[("@n", "nvarchar(40)", "N'O''Brien & '")]), + {"must_exec": True, "values_present": ["O''Brien"]}) + + # ---------- Apostrophes ------------------------------------------------- + # Apostrophe inside a parameterized statement: must be re-doubled when the + # statement is wrapped in the outer N'...' for sp_executesql. + add("apostrophe:in_param_stmt", + make_plan("(@x int)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac " + "WHERE ac.name = N'O''Brien' AND ac.column_id = @x", + params=[("@x", "int", "(1)")]), + {"must_exec": True, "stmt_contains": "O'"}) + + # Apostrophe inside a non-parameterized statement (emitted as raw text). + add("apostrophe:in_raw_stmt", + make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.name = N'D''Angelo'"), + {"must_exec": True, "stmt_contains": "D'"}) + + # ---------- Unicode / N-literal --------------------------------------- + # Literal kept as \u escapes so this file stays ASCII; at runtime the string + # carries real U+00E9 (e-acute) and U+4E2D U+6587 (Chinese). + add("unicode:n_literal", + make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac " + "WHERE ac.name = N'" + chr(0x00e9) + chr(0x4e2d) + chr(0x6587) + "'"), + {"must_exec": True, "stmt_contains": chr(0x4e2d) + chr(0x6587)}) + + # ---------- Alignment / multi-parameter -------------------------------- + add("align:autoparam_12", + make_plan("(@1 int,@2 int,@3 int,@4 int,@5 int,@6 int,@7 int,@8 int,@9 int,@10 int,@11 int,@12 int)" + "SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id IN " + "(@1,@2,@3,@4,@5,@6,@7,@8,@9,@10,@11,@12)", + params=[("@%d" % k, "int", "(%d)" % k) for k in range(1, 13)]), + {"must_exec": True, "align_check": True, + "expect_params": [("@%d" % k, "int") for k in range(1, 13)]}) + + # Declared order (@z, @a) differs from ParameterList order (@a, @z) and the + # types differ; both declaration and value lists must sort by name so value i + # binds to declaration i. + add("align:name_order_mismatch", + make_plan("(@z int, @a nvarchar(20))SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac " + "WHERE ac.column_id = @z AND ac.name = @a", + params=[("@a", "nvarchar(20)", "N'hi'"), ("@z", "int", "(5)")]), + {"must_exec": True, "align_check": True, + "expect_params": [("@a", "nvarchar(20)"), ("@z", "int")]}) + + # ---------- Control characters in a parameter value -------------------- + # A sniffed string value with an embedded CR/LF is realistic (addresses, + # JSON, SQL). The value list is built with FOR XML ... value('./text()[1]'); + # the marker after the control char must survive (not be truncated). + add("control:cr_in_value", + make_plan("(@s nvarchar(50))SELECT col_a = 1, col_s = @s, col_b = 2", + params=[("@s", "nvarchar(50)", "N'AAA" + chr(13) + "ZZZ'")]), + {"must_exec": True, "repro_contains": "ZZZ"}) + + add("control:lf_in_value", + make_plan("(@s nvarchar(50))SELECT col_a = 1, col_s = @s, col_b = 2", + params=[("@s", "nvarchar(50)", "N'AAA" + chr(10) + "ZZZ'")]), + {"must_exec": True, "repro_contains": "ZZZ"}) + + # ---------- Embedded constant ----------------------------------------- + add("const:embedded_42", + make_plan("SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id = 42", + body_extra='' + ''), + {"must_exec": True}) + + # ---------- Non-SELECT (UPDATE) against a real user table ------------- + # Needs a real table (cannot UPDATE a sys view). The harness creates + # tempdb.dbo.qrb_repro_fixture at startup and drops it at the end; the repro + # runs inside the rolled-back transaction so the write is undone. + add("update:param", + make_plan("(@r int)UPDATE f SET f.val = f.val FROM " + FIXTURE + " AS f WHERE f.id = @r", + params=[("@r", "int", "(1)")]), + {"must_exec": True}) + + # ---------- The documented '?' fill-in path (warned) ------------------ + # A parameter is declared in the query text but absent from the plan's + # ParameterList. The procedure sets its value to ? and WARNS. This is + # deliberate: the repro fails LOUD (Msg 102 near '?') rather than silently + # running with a wrong value. Assert the warning fires, the placeholder is + # present, and execution does not silently pass. + add("qmark:no_paramlist_declared", + make_plan("(@p1 int)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac WHERE ac.column_id = @p1", + params=None), + {"expect_qmark": True}) + + # Same, with a scaled type in the text: the declared numeric(10,2) must not + # be split on its internal comma while being carried into the declaration. + # The "?, ?" check is the one that can actually fail: with the comma-space + # form unsplit, @b vanishes from the value list and only one ? is emitted. + # The declaration side rebuilds as "numeric(10,2), @b int" either way, so a + # substring check on it alone cannot distinguish merged from split. + add("qmark:scaled_fillin", + make_plan("(@a numeric(10,2), @b int)SELECT c = COUNT_BIG(*) FROM sys.all_columns AS ac " + "WHERE ac.column_id = @b", params=None), + {"expect_qmark": True, "decls_contain": ["numeric(10,2)", "@b int", "?, ?"]}) + + # ---------- First-ParameterList-wins hazard ---------------------------- + # E6-style control: real ParameterList first, decoy second -> binds 555. + add("decoy:real_first_control", + make_plan("(@id int)SELECT col_a = @id, col_s = N'q', col_b = @id", + params=[("@id", "int", "(555)")], + body_extra=DECOY_PLIST), + {"echo_expect": {"a": "555", "s": "q", "b": "555"}}, echo=True) + + # E5-style probe: a decoy ParameterList (as XML-reader / UDF operators emit) + # precedes the query's own. The procedure substring-extracts the FIRST + # , so the real @id is lost -> the fill-in path warns and + # emits ? -> the repro fails LOUD. This documents that the hazard degrades + # safely (loud failure with a warning), not into a silent wrong result. + add("decoy:decoy_first_degrades_loud", + make_plan("(@id int)SELECT col_a = @id, col_s = N'q', col_b = @id", + params=[("@id", "int", "(555)")], + body_pre=DECOY_PLIST), + {"expect_qmark": True, + "note": "decoy ParameterList precedes query's -> real @id skipped, ? fill-in"}) + + # ---------- Echo cases: assert the ACTUAL bound values ----------------- + # These execute the repro and read back what was really bound, catching + # silent value/parameter reordering that would still run. + add("echo:two_int", + make_plan("(@a int, @b int)SELECT col_a = @a, col_s = N'x', col_b = @b", + params=[("@a", "int", "(999)"), ("@b", "int", "(111)")]), + {"echo_expect": {"a": "999", "s": "x", "b": "111"}}, echo=True) + + add("echo:mixed_types", + make_plan("(@a int, @z int, @m nvarchar(20))SELECT col_a = @a, col_s = @m, col_b = @z", + params=[("@a", "int", "(777)"), ("@z", "int", "(222)"), + ("@m", "nvarchar(20)", "N'mid'")]), + {"echo_expect": {"a": "777", "s": "mid", "b": "222"}}, echo=True) + + add("echo:quote_entity_value", + make_plan("(@a int, @s nvarchar(50), @b int)SELECT col_a = @a, col_s = @s, col_b = @b", + params=[("@a", "int", "(1)"), + ("@s", "nvarchar(50)", "N'O''Brien & '"), + ("@b", "int", "(2)")]), + {"echo_expect": {"a": "1", "s": "O'Brien & ", "b": "2"}}, echo=True) + + add("echo:numeric_value_paren_strip", + make_plan("(@a int, @b int)SELECT col_a = @a, col_s = N'n', col_b = @b", + params=[("@a", "int", "(-5)"), ("@b", "int", "(2147483647)")]), + {"echo_expect": {"a": "-5", "s": "n", "b": "2147483647"}}, echo=True) + + # ---------- Negative: no repro should be built ------------------------- + add("negative:no_stmtsimple", + '' + '' % NS, + {"expect_no_repro": True}) + + add("negative:not_showplan", + 'hello', + {"expect_no_repro": True}) + + return cases + + +# ------------------------------------------------------------------ assertions + +def check_case(case, gen, ex): + """Return list of (ok, label, detail).""" + out = [] + checks = case["checks"] + + def ck(ok, label, detail=""): + out.append((bool(ok), label, detail)) + + repro = gen["repro"] or "" + + # ----- negative cases: the procedure must refuse, and say why ---------- + if checks.get("expect_no_repro"): + ck(gen["repro"] is None, "no repro emitted", "repro present") + ck("has no StmtSimple" in gen["raw"], + "StmtSimple diagnostic emitted", "diagnostic message absent") + ck(not gen["sql_errors"], "no severe error on no-repro", str(gen["sql_errors"])) + return out + + # ----- '?' fill-in path: warned, builds, fails loud, never silent ------ + if checks.get("expect_qmark"): + ck(gen["repro"] is not None and gen["proc_error"] is None, + "repro built", "proc_error=%s" % gen["proc_error"]) + ck("?" in repro, "contains ? placeholder", "no ? in repro") + ck(QMARK_WARNING in repro, "missing-parameter warning fires", + "warning text absent from repro") + for v in checks.get("decls_contain", []): + ck(v in repro, "decls contain: %s" % v, "not found in repro") + # documented behavior: it must NOT silently pass with a wrong value. + ck(ex is not None and ex["exec_result"] != "PASS", + "repro fails loud (not a silent wrong result)", + "exec_result=%s" % (ex["exec_result"] if ex else None)) + return out + + # ----- echo cases: assert the actually-bound values -------------------- + if "echo_expect" in checks: + exp = checks["echo_expect"] + ck(gen["repro"] is not None and gen["proc_error"] is None, + "echo repro built", "proc_error=%s" % gen["proc_error"]) + ck(ex["exec_result"] == "PASS", "echo repro executes", + "exec_result=%s" % ex["exec_result"]) + got = ex["echo"] + ck(got is not None, "echo captured", + "no ECHO line (exec_result=%s)" % ex["exec_result"]) + if got: + for k in ("a", "s", "b"): + ck(got[k] == exp[k], "echo %s == %r" % (k, exp[k]), "got %r" % got[k]) + return out + + # ----- normal cases ---------------------------------------------------- + ck(gen["repro"] is not None and gen["proc_error"] is None, + "repro built without proc error", + "proc_error=%s no_repro=%s" % (gen["proc_error"], gen["no_repro"])) + + if checks.get("min_repro_len"): + ck(len(repro) >= checks["min_repro_len"], + "repro length >= %d" % checks["min_repro_len"], "len=%d" % len(repro)) + + if checks.get("stmt_contains"): + ck(checks["stmt_contains"] in repro, "statement text preserved", + "missing %r" % checks["stmt_contains"]) + + if checks.get("repro_contains"): + ck(checks["repro_contains"] in repro, "value not truncated (marker present)", + "marker %r absent" % checks["repro_contains"]) + + for v in (checks.get("values_present") or []): + ck(v in repro, "value present: %s" % v, "not found in repro") + + # parameter declaration count + types + if checks.get("expect_params") is not None: + expect = checks["expect_params"] + body = repro[repro.find("EXECUTE sys.sp_executesql"):] \ + if "EXECUTE sys.sp_executesql" in repro else "" + segs = sql_literal_segments(body) + if len(segs) >= 2: + decls = segs[1][2] + got_names = re.findall(r"@\w+", decls) + ck(len(got_names) == len(expect), "param count == %d" % len(expect), + "got %d (%s)" % (len(got_names), decls[:160])) + for (nm, dt) in expect: + pat = re.escape(nm) + r"\s+" + re.escape(dt) + r"(?:\s*,|\s*$)" + ck(re.search(pat, decls) is not None, + "decl %s %s present" % (nm, dt), "decls=%s" % decls[:200]) + else: + ck(False, "sp_executesql decls found", "segments=%d" % len(segs)) + + # declaration list and value list must be positionally consistent + if checks.get("align_check"): + body = repro[repro.find("EXECUTE sys.sp_executesql"):] \ + if "EXECUTE sys.sp_executesql" in repro else "" + segs = sql_literal_segments(body) + if len(segs) >= 2: + decls = segs[1][2] + parts = [p.strip() for p in re.split(r",\s*(?=@)", decls) if p.strip()] + names = [] + for p in parts: + mm = re.match(r"(@\w+)\s+(.+)", p) + if mm: + names.append((mm.group(1), mm.group(2).strip())) + after = body[segs[1][1] + 1:].split(";", 1)[0] + after = after.lstrip().lstrip(",").lstrip() + vals = split_top_values(after) + ck(len(names) == len(vals), "decl count == value count", + "decls=%d vals=%d :: %s || %s" % (len(names), len(vals), decls[:120], after[:120])) + for ((nm, dt), val) in zip(names, vals): + dtl = dt.strip().lower() + v = val.strip() + if dtl.startswith(("numeric", "decimal", "int", "bigint", "smallint", + "tinyint", "float", "real", "money")): + ck(not v.startswith("N'") and not v.startswith("'"), + "numeric param %s not bound to a string value" % nm, "val=%r" % v) + if dtl.startswith(("nvarchar", "varchar", "nchar", "char", "sysname")): + ck(v.startswith("N'") or v.startswith("'") or v in ("NULL", "?"), + "string param %s bound to a string value" % nm, "val=%r" % v) + else: + ck(False, "align: sp_executesql decls found", "segments=%d" % len(segs)) + + # the point of the whole thing: it must RUN, not just parse + if checks.get("must_exec"): + ck(ex is not None and ex["exec_result"] == "PASS", + "repro EXECUTES (runs, not just parses)", + "exec_result=%s" % (ex["exec_result"] if ex else None)) + ck(not (ex or {}).get("sql_errors"), + "no severe SQL error during execution", str((ex or {}).get("sql_errors"))) + + return out + + +# ------------------------------------------------------------------ fixture + +FIXTURE_SETUP = """\ +SET NOCOUNT ON; +IF OBJECT_ID('tempdb.dbo.qrb_repro_fixture') IS NOT NULL + DROP TABLE tempdb.dbo.qrb_repro_fixture; +CREATE TABLE tempdb.dbo.qrb_repro_fixture +( + id integer NOT NULL, + val integer NOT NULL, + name nvarchar(50) NULL +); +INSERT tempdb.dbo.qrb_repro_fixture (id, val, name) +VALUES (1, 10, N'a'), (2, 20, N'b'), (3, 30, N'c'); +""" + +FIXTURE_TEARDOWN = """\ +IF OBJECT_ID('tempdb.dbo.qrb_repro_fixture') IS NOT NULL + DROP TABLE tempdb.dbo.qrb_repro_fixture; +""" + + +def _run_inline(server, password, sql, tag): + path = os.path.join(WORKDIR, "_%s.sql" % tag) + _write_sql(path, sql) + return _run_sql_file(server, password, path) + + +# ------------------------------------------------------------------ main + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server", default="SQL2022") + ap.add_argument("--password", default="L!nt0044") + ap.add_argument("--only", default=None, help="substring filter on case name") + ap.add_argument("--verbose", action="store_true") + args = ap.parse_args() + + # A failure detail can contain non-ASCII (e.g. the unicode case); make sure + # printing it never crashes on a Windows console code page. + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + + print("Running sp_QueryReproBuilder generate-and-execute tests against %s..." % args.server) + print() + + setup_out = _run_inline(args.server, args.password, FIXTURE_SETUP, "fixture_setup") + setup_err = find_sql_errors(setup_out) + if setup_err: + print("ERROR: could not create the tempdb fixture:") + for e in setup_err: + print(" " + e) + sys.exit(1) + + cases = build_cases() + if args.only: + cases = [c for c in cases if args.only in c["name"]] + + results = [] + try: + for case in cases: + gen = generate(args.server, args.password, case["plan"]) + ex = None + need_exec = ("echo_expect" in case["checks"] + or case["checks"].get("must_exec") + or case["checks"].get("expect_qmark")) + if gen["repro"] is not None and need_exec: + ex = execute(args.server, args.password, gen["repro"], echo=case["echo"]) + checks = check_case(case, gen, ex) + case_failed = any(not ok for (ok, _l, _d) in checks) + for (ok, label, detail) in checks: + results.append((case["name"], ok, label, detail)) + if case_failed and not args.verbose: + exr = ex["exec_result"] if ex else None + print("FAIL %s" % case["name"]) + for (ok, label, detail) in checks: + if not ok: + print(" - %s :: %s" % (label, detail)) + print(" exec_result=%s proc_error=%s" % (exr, gen["proc_error"])) + if gen["repro"]: + print(" repro[:300]=%s" % gen["repro"][:300].replace("\n", "\\n")) + elif not case_failed: + print("PASS %s" % case["name"]) + if args.verbose: + for (ok, label, detail) in checks: + print(" - %s" % label) + finally: + _run_inline(args.server, args.password, FIXTURE_TEARDOWN, "fixture_teardown") + + passed = sum(1 for (_n, ok, _l, _d) in results if ok) + failed = sum(1 for (_n, ok, _l, _d) in results if not ok) + + print() + print("Results: %d passed, %d failed, %d total" % (passed, failed, len(results))) + + if failed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/sp_QueryReproBuilder/tests/template_execute.sql b/sp_QueryReproBuilder/tests/template_execute.sql new file mode 100644 index 00000000..ca0a4a93 --- /dev/null +++ b/sp_QueryReproBuilder/tests/template_execute.sql @@ -0,0 +1,40 @@ +/* +Execution half of the sp_QueryReproBuilder generate-and-execute harness. + +This is the check that earns the harness its keep. A repro that reads correctly +but will not run is the characteristic failure mode of a generator like this, and +only executing the generated script catches it. SET PARSEONLY ON would sail past +a semantic error; running the repro for real does not. + +run_tests.py hands the emitted repro back in as base64 of its utf-16-le bytes +(assigned in @@B64_ASSIGNMENTS@@), so there is no quote-escaping to get wrong and +no 8000-character string-literal limit to trip over on long repros. The repro +lands in a genuine nvarchar(max) variable and is executed with +sys.sp_executesql inside BEGIN TRANSACTION ... ROLLBACK and TRY/CATCH: + + - the transaction rolls back any writes the repro performs (e.g. an UPDATE), + - sp_executesql defers the repro's compile, so a compile or semantic error in + the generated SQL is catchable here instead of aborting the whole batch, and + - EXEC_RESULT reports PASS only when the repro actually ran. + +@@RUNNER@@ is replaced with either a plain execute or, for echo cases, an +INSERT ... EXECUTE into a known 3-column #echo table so the actually-bound +parameter values can be read back and asserted. +*/ +SET NOCOUNT ON; +SET XACT_ABORT OFF; + +DECLARE + @b64 varchar(max) = CONVERT(varchar(max), ''); + +@@B64_ASSIGNMENTS@@ + +DECLARE + @repro nvarchar(max) = + CONVERT + ( + nvarchar(max), + CAST(N'' AS xml).value('xs:base64Binary(sql:variable("@b64"))', 'varbinary(max)') + ); + +@@RUNNER@@ diff --git a/sp_QueryReproBuilder/tests/template_generate.sql b/sp_QueryReproBuilder/tests/template_generate.sql new file mode 100644 index 00000000..a0cb9ed7 --- /dev/null +++ b/sp_QueryReproBuilder/tests/template_generate.sql @@ -0,0 +1,31 @@ +/* +Generation half of the sp_QueryReproBuilder generate-and-execute harness. + +run_tests.py substitutes @@PLAN@@ with a ShowPlanXML document (single-quotes +already doubled) and runs this file. The procedure is driven in +@query_plan_xml mode, which bypasses Query Store and is the deterministic entry +point. Its primary result set (table_name = 'results') prints to stdout; +run_tests.py extracts the emitted repro from the executable_query processing +instruction that renders as . + +This is deliberately NOT an INSERT ... EXECUTE capture. sp_QueryReproBuilder can +return different result-set shapes (the wide 'results' set when a repro is built +versus the single-column '#repro_queries is empty' diagnostic when nothing +lands), and a fixed-shape INSERT ... EXECUTE target cannot absorb both. Driving +it through stdout keeps the harness correct no matter which shape comes back. +*/ +SET NOCOUNT ON; +SET XACT_ABORT OFF; + +DECLARE + @plan xml = CONVERT(xml, N'@@PLAN@@'); + +BEGIN TRY + EXECUTE dbo.sp_QueryReproBuilder + @query_plan_xml = @plan; +END TRY +BEGIN CATCH + PRINT 'PROC_ERROR: Msg ' + CONVERT(varchar(20), ERROR_NUMBER()) + + ' Lvl ' + CONVERT(varchar(20), ERROR_SEVERITY()) + + ' : ' + ERROR_MESSAGE(); +END CATCH; diff --git a/sp_QueryStoreCleanup/README.md b/sp_QueryStoreCleanup/README.md index 795a6612..35188754 100644 --- a/sp_QueryStoreCleanup/README.md +++ b/sp_QueryStoreCleanup/README.md @@ -15,7 +15,7 @@ Queries with forced plans are always protected from removal. | parameter_name | data_type | description | valid_inputs | defaults | |---|---|---|---|---| | @database_name | sysname | the database to clean query store in | a database name with query store enabled | NULL; current database if NULL | -| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none | all | +| @cleanup_targets | varchar(100) | what to target for cleanup | all, system, maintenance (or maint), custom, none, or comma-separated combination | all | | @custom_query_filter | nvarchar(1024) | custom LIKE pattern for query text filtering; also applied when @cleanup_targets = all | a valid LIKE pattern | NULL | | @dedupe_by | varchar(50) | deduplication strategy | all, query_hash, plan_hash, none | all | | @min_age_days | integer | only remove queries whose last execution is older than this many days | a positive integer | NULL; no age filter | diff --git a/sp_QuickieCache/README.md b/sp_QuickieCache/README.md index 3ad47ef0..1834bb7c 100644 --- a/sp_QuickieCache/README.md +++ b/sp_QuickieCache/README.md @@ -34,15 +34,20 @@ While QuickieStore digs into Query Store data, QuickieCache uses the same Pareto | parameter_name | data_type | description | default | |---|---|---|---| -| @top | integer | candidates per metric dimension before dedup | 10 | +| @top | bigint | candidates per metric dimension before dedup | 10 | +| @sort_order | varchar | secondary sort after impact_score | cpu | | @database_name | sysname | filter to a specific database | NULL | | @start_date | datetime | only include plans created after this date | NULL | | @end_date | datetime | only include plans created before this date | NULL | | @minimum_execution_count | bigint | minimum execution count to include a query | 2 | -| @ignore_system_databases | bit | exclude system databases | 1 | +| @ignore_system_databases | bit | exclude system databases (master, model, msdb, tempdb) | 1 | | @impact_threshold | decimal(3,2) | minimum impact_score to surface | 0.50 | +| @find_single_use_plans | bit | show single-use plans consuming the most memory | 0 | +| @find_duplicate_plans | bit | show query hashes with multiple cached plans | 0 | | @debug | bit | print diagnostic information | 0 | | @help | bit | display parameter help | 0 | +| @version | varchar | OUTPUT; for support | none; OUTPUT | +| @version_date | datetime | OUTPUT; for support | none; OUTPUT | ## Examples diff --git a/sp_QuickieCache/sp_QuickieCache.sql b/sp_QuickieCache/sp_QuickieCache.sql index ed5cb633..baa28ebf 100644 --- a/sp_QuickieCache/sp_QuickieCache.sql +++ b/sp_QuickieCache/sp_QuickieCache.sql @@ -78,8 +78,8 @@ BEGIN SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT - @version = '1.7', - @version_date = '20260701'; + @version = '1.8', + @version_date = '20260801'; /* ╔══════════════════════════════════════════════════╗ @@ -569,6 +569,204 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. RETURN; END; + /* + ╔══════════════════════════════════════════════════╗ + ║ Step 0: Materialize the query stats DMV ║ + ╚══════════════════════════════════════════════════╝ + + sys.dm_exec_query_stats is a streaming view over the plan cache, + not a table. There is nothing in it to seek, so every reference to + it is a full scan of the cache stores, and the optimizer costs it + with a fixed row guess no matter how many plans are really cached. + + Every consumer below used to read it directly: one full cache scan + for the plan age distribution, one for the duplicate hash summary, + two more for the duplicate plan detail, one for the statement + aggregation, and then — worst of all — a correlated CROSS APPLY + back into it to pick each collected hash's sample handles. Nothing + in the DMV is seekable, so that apply nested-loops: the optimizer + spools the whole thing and then rewinds, refilters, and re-sorts + every cached statement once per collected query_hash. The work grew + with (hashes × cached statements) rather than with the cache. + + Read it once into a temp table instead: one pass over the cache, + one sys.dm_exec_plan_attributes call per row, and every query below + then runs against real cardinality and real statistics. + + Only the database filters are applied here, because they are the + only ones every consumer shares. The query_hash, date, and + execution count filters stay with the queries that want them; the + health checks deliberately measure the whole cache rather than the + filtered slice of it. + + The memory grant and spill columns are the only reason any of this + needs dynamic SQL. Version-gating them once, here, is what lets + every query below be static: on versions without them we store + NULL, so SUM() and MAX() produce the same zeros and NULLs that the + old version-gated column lists produced by omitting them. + */ + DECLARE + @sql nvarchar(max) = N''; + + CREATE TABLE + #dm_exec_query_stats + ( + database_id integer NULL, + query_hash binary(8) NULL, + plan_handle varbinary(64) NULL, + sql_handle varbinary(64) NULL, + statement_start_offset integer NULL, + statement_end_offset integer NULL, + execution_count bigint NULL, + total_worker_time bigint NULL, + total_elapsed_time bigint NULL, + total_logical_reads bigint NULL, + total_logical_writes bigint NULL, + total_physical_reads bigint NULL, + total_rows bigint NULL, + min_rows bigint NULL, + max_rows bigint NULL, + min_worker_time bigint NULL, + max_worker_time bigint NULL, + min_physical_reads bigint NULL, + max_physical_reads bigint NULL, + min_elapsed_time bigint NULL, + max_elapsed_time bigint NULL, + max_dop bigint NULL, + max_grant_kb bigint NULL, + max_used_grant_kb bigint NULL, + total_spills bigint NULL, + max_spills bigint NULL, + creation_time datetime NULL, + last_execution_time datetime NULL + ); + + SELECT + @sql = N' +INSERT + #dm_exec_query_stats +WITH + (TABLOCK) +( + database_id, + query_hash, + plan_handle, + sql_handle, + statement_start_offset, + statement_end_offset, + execution_count, + total_worker_time, + total_elapsed_time, + total_logical_reads, + total_logical_writes, + total_physical_reads, + total_rows, + min_rows, + max_rows, + min_worker_time, + max_worker_time, + min_physical_reads, + max_physical_reads, + min_elapsed_time, + max_elapsed_time, + max_dop, + max_grant_kb, + max_used_grant_kb, + total_spills, + max_spills, + creation_time, + last_execution_time +) +SELECT + database_id = + CONVERT(integer, pa.value), + qs.query_hash, + qs.plan_handle, + qs.sql_handle, + qs.statement_start_offset, + qs.statement_end_offset, + qs.execution_count, + qs.total_worker_time, + qs.total_elapsed_time, + qs.total_logical_reads, + qs.total_logical_writes, + qs.total_physical_reads, + qs.total_rows, + qs.min_rows, + qs.max_rows, + qs.min_worker_time, + qs.max_worker_time, + qs.min_physical_reads, + qs.max_physical_reads, + qs.min_elapsed_time, + qs.max_elapsed_time, + qs.max_dop,' + + CASE + WHEN @has_memory_grants = 1 + THEN N' + max_grant_kb = ISNULL(qs.max_grant_kb, 0), + max_used_grant_kb = ISNULL(qs.max_used_grant_kb, 0),' + ELSE N' + max_grant_kb = NULL, + max_used_grant_kb = NULL,' + END + + CASE + WHEN @has_spills = 1 + THEN N' + total_spills = ISNULL(qs.total_spills, 0), + max_spills = ISNULL(qs.max_spills, 0),' + ELSE N' + total_spills = NULL, + max_spills = NULL,' + END + N' + qs.creation_time, + qs.last_execution_time +FROM sys.dm_exec_query_stats AS qs +CROSS APPLY +( + SELECT TOP (1) + value = pa.value + FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa + WHERE pa.attribute = N''dbid'' +) AS pa +WHERE ISNULL(CONVERT(integer, pa.value), 0) < 32761' + + CASE + WHEN @ignore_system_databases = 1 + THEN N' +AND ISNULL(CONVERT(integer, pa.value), 0) NOT IN (1, 2, 3, 4)' + ELSE N'' + END + + CASE + WHEN @database_id IS NOT NULL + THEN N' +AND CONVERT(integer, pa.value) = @database_id' + ELSE N'' + END + N' +OPTION(RECOMPILE, MAXDOP 1);'; + + IF @debug = 1 + BEGIN + RAISERROR(N'Plan cache materialization SQL:', 0, 1) WITH NOWAIT; + RAISERROR(N'%s', 0, 1, @sql) WITH NOWAIT; + END; + + EXECUTE sys.sp_executesql + @sql, + N'@database_id integer', + @database_id; + + IF @debug = 1 + BEGIN + DECLARE + @dmv_rows bigint; + + SELECT + @dmv_rows = COUNT_BIG(*) + FROM #dm_exec_query_stats AS s; + + RAISERROR(N'Plan cache rows materialized: %I64d', 0, 1, @dmv_rows) WITH NOWAIT; + END; + /* ╔══════════════════════════════════════════════════╗ ║ Plan cache health analysis ║ @@ -597,11 +795,16 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sys.dm_exec_cached_plans — the number the user actually sees in the cache — so the finding reconciles with what they observe. The age buckets are computed at plan grain (DISTINCT plan_handle) from - sys.dm_exec_query_stats, the only source with per-plan compile + #dm_exec_query_stats, the only source with per-plan compile times. Counting raw query_stats rows (statement grain) inflated every number, because a multi-statement plan contributes one row per statement; @total_plans is now distinct plans with execution stats, the consistent denominator for the recency percentages. + + The database filters were already applied when #dm_exec_query_stats + was populated, so there is nothing left to filter here. The date + filters are deliberately NOT applied: this measures the health of + the whole cache, not of the slice the caller asked to analyze. */ DECLARE @total_plans bigint = 0, @@ -631,47 +834,92 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id) OPTION(RECOMPILE); + /* + The buckets are counted by first collapsing the statement-grained + rows down to one row per plan, then adding up the plans that fall in + each window. + + The obvious way to write this is + COUNT_BIG(DISTINCT CASE WHEN ... THEN plan_handle END), and that is + what it used to be. Every plan OUTSIDE the window makes that CASE + return NULL, COUNT ignores it, and SQL Server says so: "Null value + is eliminated by an aggregate or other SET operation" — printed on + every run against any cache holding plans older than an hour, which + is every real cache. Counting 1s and 0s over a pre-deduplicated set + gets the same answers without ever handing an aggregate a NULL. + + Bucket on the NEWEST compile, and ONLY on the newest compile. + + creation_time is a property of the STATEMENT, not of the plan: when + a single statement inside a batch recompiles, it restamps its own + creation_time (and its own plan_generation_num) while the batch + keeps its plan_handle. So rows sharing a plan_handle routinely carry + different creation_times — days apart, on a cache with any recompile + activity at all. + + That makes the choice of MIN vs MAX load-bearing rather than + cosmetic. COUNT_BIG(DISTINCT CASE ...) counted a plan as recent if + ANY of its rows landed in the window, which is MAX semantics. + Collapsing on MIN instead would ask whether the plan's OLDEST + statement is recent, silently under-reporting every bucket and + letting a cache that churns through statement-level recompiles — + exactly what this check exists to catch — report as healthy. + + @oldest_plan_date still wants the true minimum, so the derived table + carries both ends. + */ SELECT - @total_plans = COUNT_BIG(DISTINCT qs.plan_handle), + @total_plans = COUNT_BIG(*), @plans_24h = - COUNT_BIG + ISNULL ( - DISTINCT - CASE - WHEN DATEDIFF(HOUR, qs.creation_time, GETDATE()) <= 24 - THEN qs.plan_handle - END + SUM + ( + CASE + WHEN DATEDIFF(HOUR, p.newest_compile, GETDATE()) <= 24 + THEN 1 + ELSE 0 + END + ), + 0 ), @plans_4h = - COUNT_BIG + ISNULL ( - DISTINCT - CASE - WHEN DATEDIFF(HOUR, qs.creation_time, GETDATE()) <= 4 - THEN qs.plan_handle - END + SUM + ( + CASE + WHEN DATEDIFF(HOUR, p.newest_compile, GETDATE()) <= 4 + THEN 1 + ELSE 0 + END + ), + 0 ), @plans_1h = - COUNT_BIG + ISNULL ( - DISTINCT - CASE - WHEN DATEDIFF(HOUR, qs.creation_time, GETDATE()) <= 1 - THEN qs.plan_handle - END + SUM + ( + CASE + WHEN DATEDIFF(HOUR, p.newest_compile, GETDATE()) <= 1 + THEN 1 + ELSE 0 + END + ), + 0 ), - @oldest_plan_date = MIN(qs.creation_time) - FROM sys.dm_exec_query_stats AS qs - CROSS APPLY + @oldest_plan_date = MIN(p.oldest_compile) + FROM ( - SELECT TOP (1) - value = pa.value - FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa - WHERE pa.attribute = N'dbid' - ) AS pa - WHERE (@ignore_system_databases = 0 OR ISNULL(CONVERT(integer, pa.value), 0) NOT IN (1, 2, 3, 4)) - AND ISNULL(CONVERT(integer, pa.value), 0) < 32761 - AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id) + SELECT + s.plan_handle, + newest_compile = MAX(s.creation_time), + oldest_compile = MIN(s.creation_time) + FROM #dm_exec_query_stats AS s + GROUP BY + s.plan_handle + ) AS p OPTION(RECOMPILE); IF @total_plans > 0 @@ -736,9 +984,8 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Single-use plan bloat per database: A high percentage of single-use adhoc/prepared plans suggests an unparameterized ad hoc workload that bloats the plan cache and may - benefit from Optimize for Ad Hoc Workloads and/or Forced - Parameterization. Only surfaces databases where single-use plans - exceed 10% of that database's cached compiled plans. + benefit from Forced Parameterization. Only surfaces databases where + single-use plans exceed 10% of that database's cached compiled plans. Sourced from sys.dm_exec_cached_plans on purpose, NOT sys.dm_exec_query_stats. query_stats is statement-grained and only @@ -785,7 +1032,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. N' of ' + FORMAT(x.total_count, N'N0') + N' cached plans (' + CONVERT(nvarchar(10), x.single_use_pct) + - N'%) are single-use adhoc or prepared plans. Consider Optimize for Ad Hoc Workloads, and Forced Parameterization if these are unparameterized ad hoc queries.' + N'%) are single-use adhoc or prepared plans. Consider Forced Parameterization if these are unparameterized ad hoc queries.' FROM ( SELECT @@ -854,23 +1101,13 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. FROM ( SELECT - plan_count = COUNT_BIG(DISTINCT qs.plan_handle) - FROM sys.dm_exec_query_stats AS qs - CROSS APPLY - ( - SELECT TOP (1) - value = pa.value - FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa - WHERE pa.attribute = N'dbid' - ) AS pa - WHERE qs.query_hash <> 0x0000000000000000 - AND (@ignore_system_databases = 0 OR ISNULL(CONVERT(integer, pa.value), 0) NOT IN (1, 2, 3, 4)) - AND ISNULL(CONVERT(integer, pa.value), 0) < 32761 - AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id) + plan_count = COUNT_BIG(DISTINCT s.plan_handle) + FROM #dm_exec_query_stats AS s + WHERE s.query_hash <> 0x0000000000000000 GROUP BY - qs.query_hash + s.query_hash HAVING - COUNT_BIG(DISTINCT qs.plan_handle) > 5 + COUNT_BIG(DISTINCT s.plan_handle) > 5 ) AS x OPTION(RECOMPILE); @@ -905,7 +1142,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THEN N'Massive duplicate plan compilation' ELSE N'Notable duplicate plan compilation' END, - database_name = DB_NAME(CONVERT(integer, pa.value)), + database_name = DB_NAME(s2.database_id), priority = CASE WHEN @pct_duplicate > 75 @@ -913,38 +1150,36 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ELSE 254 END, details = - FORMAT(COUNT_BIG(DISTINCT qs2.query_hash), N'N0') + + FORMAT(COUNT_BIG(DISTINCT s2.query_hash), N'N0') + N' query hashes with 5+ plans, totaling ' + - FORMAT(COUNT_BIG(DISTINCT qs2.plan_handle), N'N0') + N' plans. ' + + FORMAT(COUNT_BIG(DISTINCT s2.plan_handle), N'N0') + N' plans. ' + N'Most likely unparameterized queries. SET option differences between sessions can also cause this. Consider Forced Parameterization.' + /* + Both sides now read the same filtered set, so the hashes counted + here are the same hashes @duplicate_hashes and @duplicate_plans + were computed from. The inner query used to scan the DMV with no + database predicate at all while the outer one was filtered, which + let a hash qualify on the strength of plans in databases the + caller had excluded. + */ FROM ( SELECT - qs.query_hash - FROM sys.dm_exec_query_stats AS qs - WHERE qs.query_hash <> 0x0000000000000000 + s.query_hash + FROM #dm_exec_query_stats AS s + WHERE s.query_hash <> 0x0000000000000000 GROUP BY - qs.query_hash + s.query_hash HAVING - COUNT_BIG(DISTINCT qs.plan_handle) > 5 + COUNT_BIG(DISTINCT s.plan_handle) > 5 ) AS x - JOIN sys.dm_exec_query_stats AS qs2 - ON qs2.query_hash = x.query_hash - CROSS APPLY - ( - SELECT TOP (1) - value = pa.value - FROM sys.dm_exec_plan_attributes(qs2.plan_handle) AS pa - WHERE pa.attribute = N'dbid' - ) AS pa - WHERE pa.value IS NOT NULL - AND (@ignore_system_databases = 0 OR CONVERT(integer, pa.value) NOT IN (1, 2, 3, 4)) - AND CONVERT(integer, pa.value) < 32761 - AND (@database_id IS NULL OR CONVERT(integer, pa.value) = @database_id) + JOIN #dm_exec_query_stats AS s2 + ON s2.query_hash = x.query_hash + WHERE s2.database_id IS NOT NULL GROUP BY - pa.value + s2.database_id ORDER BY - COUNT_BIG(DISTINCT qs2.plan_handle) DESC + COUNT_BIG(DISTINCT s2.plan_handle) DESC OPTION(RECOMPILE); END; @@ -1085,170 +1320,238 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. sample_statement_end integer NULL ); - DECLARE - @sql nvarchar(max) = N''; - /* ╔══════════════════════════════════════════════════╗ ║ Step 1a: Collect statement-level stats ║ - ║ (sys.dm_exec_query_stats, grouped by hash) ║ + ║ (#dm_exec_query_stats, grouped by hash) ║ ╚══════════════════════════════════════════════════╝ + + Two things happen here, and they are deliberately kept apart. + + "totals" rolls the hash group up. "winner" picks the single plan + that represents it — the most-executed one — and that one row + supplies the database, both handles, and both offsets, so the text, + the offsets, and the plan always describe the same plan instead of + being MAX()'d independently out of different ones. + + The winner could instead be folded into the aggregate as + MAX(CASE WHEN n = 1 THEN ... END). Don't. Every row that isn't the + winner feeds a NULL into those MAX()es, and SQL Server answers with + "Null value is eliminated by an aggregate or other SET operation" on + every run. Joining to the winner asks the same question without ever + handing an aggregate a NULL. The object path in Step 1b does the + same thing for the same reason. + + Reading #dm_exec_query_stats twice — once for totals, once for the + winner — is safe precisely BECAUSE it is a temp table. It holds + still between the two reads. Do not repoint either side of this join + at sys.dm_exec_query_stats directly: a DMV gets no statement-level + snapshot, so the two sides would see two different plan caches and + the inner join would silently drop any hash that got evicted in + between. + + This all used to be a second pass — an UPDATE that CROSS APPLY'd + sys.dm_exec_query_stats per collected query_hash to go find those + handles. Nothing in the DMV is seekable, so the apply nested-loops + over it: spool the whole cache, then rewind, refilter, and re-sort + it once per hash. It also carried none of the database or date + predicates, so with @database_name set it could hand back the + handles of a plan from a database the caller had explicitly + filtered out, and then show its text and its plan. */ + INSERT + #query_stats + WITH + (TABLOCK) + ( + query_type, + database_name, + query_hash, + plan_count, + total_executions, + total_cpu_ms, + total_duration_ms, + total_logical_reads, + total_logical_writes, + total_physical_reads, + total_rows, + total_grant_mb, + total_used_grant_mb, + total_spills, + max_grant_mb, + max_used_grant_mb, + max_spills, + max_dop, + min_rows, + max_rows, + min_cpu_ms, + max_cpu_ms, + min_physical_reads, + max_physical_reads, + min_duration_ms, + max_duration_ms, + oldest_plan_creation, + newest_plan_creation, + last_execution_time, + sample_sql_handle, + sample_plan_handle, + sample_statement_start, + sample_statement_end + ) SELECT - @sql = N' -INSERT - #query_stats -WITH - (TABLOCK) -( - query_type, - database_name, - query_hash, - plan_count, - total_executions, - total_cpu_ms, - total_duration_ms, - total_logical_reads, - total_logical_writes, - total_physical_reads, - total_rows,' + - CASE - WHEN @has_memory_grants = 1 - THEN N' - total_grant_mb, - total_used_grant_mb, - max_grant_mb, - max_used_grant_mb,' - ELSE N'' - END + - CASE - WHEN @has_spills = 1 - THEN N' - total_spills, - max_spills,' - ELSE N'' - END + N' - max_dop, - min_rows, - max_rows, - min_cpu_ms, - max_cpu_ms, - min_physical_reads, - max_physical_reads, - min_duration_ms, - max_duration_ms, - oldest_plan_creation, - newest_plan_creation, - last_execution_time -) -SELECT - query_type = ''Statement'', - database_name = - DB_NAME - ( - CONVERT - ( - integer, - MAX(pa.value) - ) - ), - query_hash = qs.query_hash, - plan_count = COUNT_BIG(DISTINCT qs.plan_handle), - total_executions = SUM(qs.execution_count), - total_cpu_ms = SUM(qs.total_worker_time) / 1000.0, - total_duration_ms = SUM(qs.total_elapsed_time) / 1000.0, - total_logical_reads = SUM(qs.total_logical_reads), - total_logical_writes = SUM(qs.total_logical_writes), - total_physical_reads = SUM(qs.total_physical_reads), - total_rows = SUM(qs.total_rows),' + - CASE - WHEN @has_memory_grants = 1 - THEN N' - total_grant_mb = SUM(ISNULL(qs.max_grant_kb, 0)) / 1024.0, - total_used_grant_mb = SUM(ISNULL(qs.max_used_grant_kb, 0)) / 1024.0, - max_grant_mb = MAX(ISNULL(qs.max_grant_kb, 0)) / 1024.0, - max_used_grant_mb = MAX(ISNULL(qs.max_used_grant_kb, 0)) / 1024.0,' - ELSE N'' - END + - CASE - WHEN @has_spills = 1 - THEN N' - total_spills = SUM(ISNULL(qs.total_spills, 0)), - max_spills = MAX(ISNULL(qs.max_spills, 0)),' - ELSE N'' - END + N' - max_dop = MAX(qs.max_dop), - min_rows = MIN(qs.min_rows), - max_rows = MAX(qs.max_rows), - min_cpu_ms = MIN(qs.min_worker_time) / 1000.0, - max_cpu_ms = MAX(qs.max_worker_time) / 1000.0, - min_physical_reads = MIN(qs.min_physical_reads), - max_physical_reads = MAX(qs.max_physical_reads), - min_duration_ms = MIN(qs.min_elapsed_time) / 1000.0, - max_duration_ms = MAX(qs.max_elapsed_time) / 1000.0, - oldest_plan_creation = MIN(qs.creation_time), - newest_plan_creation = MAX(qs.creation_time), - last_execution_time = MAX(qs.last_execution_time) -FROM sys.dm_exec_query_stats AS qs -CROSS APPLY -( - SELECT TOP (1) - value = pa.value - FROM sys.dm_exec_plan_attributes(qs.plan_handle) AS pa - WHERE pa.attribute = N''dbid'' -) AS pa -WHERE qs.query_hash <> 0x0000000000000000' + - /* @minimum_execution_count is enforced ONLY in the HAVING - SUM(execution_count) below — applying it per-row here - filtered out individual plans whose single-plan execution_count - was below the floor but whose group total was above it - (think: a recompile-heavy query with many plans each run a - few times that add up to a lot). Same reasoning applies to - the procedure / function / trigger paths further down. */ - CASE - WHEN @ignore_system_databases = 1 - THEN N' -AND ISNULL(pa.value, 0) NOT IN (1, 2, 3, 4) -AND ISNULL(pa.value, 0) < 32761' - ELSE N'' - END + - CASE - WHEN @database_id IS NOT NULL - THEN N' -AND CONVERT(integer, pa.value) = @database_id' - ELSE N'' - END + - CASE - WHEN @start_date IS NOT NULL - THEN N' -AND qs.creation_time >= @start_date' - ELSE N'' - END + - CASE - WHEN @end_date IS NOT NULL - THEN N' -AND qs.creation_time < @end_date' - ELSE N'' - END + N' -GROUP BY - qs.query_hash -HAVING - SUM(qs.execution_count) >= @minimum_execution_count -OPTION(RECOMPILE, MAXDOP 1);'; - - IF @debug = 1 - BEGIN - RAISERROR(N'Statement aggregation SQL:', 0, 1) WITH NOWAIT; - RAISERROR(N'%s', 0, 1, @sql) WITH NOWAIT; - END; - - EXECUTE sys.sp_executesql - @sql, - N'@minimum_execution_count bigint, @database_id integer, @start_date datetime, @end_date datetime', - @minimum_execution_count, - @database_id, - @start_date, - @end_date; + query_type = 'Statement', + database_name = DB_NAME(winner.database_id), + query_hash = totals.query_hash, + plan_count = totals.plan_count, + total_executions = totals.total_executions, + total_cpu_ms = totals.total_cpu_ms, + total_duration_ms = totals.total_duration_ms, + total_logical_reads = totals.total_logical_reads, + total_logical_writes = totals.total_logical_writes, + total_physical_reads = totals.total_physical_reads, + total_rows = totals.total_rows, + total_grant_mb = totals.total_grant_mb, + total_used_grant_mb = totals.total_used_grant_mb, + total_spills = totals.total_spills, + max_grant_mb = totals.max_grant_mb, + max_used_grant_mb = totals.max_used_grant_mb, + max_spills = totals.max_spills, + max_dop = totals.max_dop, + min_rows = totals.min_rows, + max_rows = totals.max_rows, + min_cpu_ms = totals.min_cpu_ms, + max_cpu_ms = totals.max_cpu_ms, + min_physical_reads = totals.min_physical_reads, + max_physical_reads = totals.max_physical_reads, + min_duration_ms = totals.min_duration_ms, + max_duration_ms = totals.max_duration_ms, + oldest_plan_creation = totals.oldest_plan_creation, + newest_plan_creation = totals.newest_plan_creation, + last_execution_time = totals.last_execution_time, + sample_sql_handle = winner.sql_handle, + sample_plan_handle = winner.plan_handle, + sample_statement_start = winner.statement_start_offset, + sample_statement_end = winner.statement_end_offset + FROM + ( + SELECT + s.query_hash, + plan_count = COUNT_BIG(DISTINCT s.plan_handle), + total_executions = SUM(s.execution_count), + total_cpu_ms = SUM(s.total_worker_time) / 1000.0, + total_duration_ms = SUM(s.total_elapsed_time) / 1000.0, + total_logical_reads = SUM(s.total_logical_reads), + total_logical_writes = SUM(s.total_logical_writes), + total_physical_reads = SUM(s.total_physical_reads), + total_rows = SUM(s.total_rows), + /* + On versions without the memory grant and spill columns these + are NULL for every row, and the CASE hands back the same 0s + and NULLs the old version-gated column lists produced by + omitting them: the totals are NOT NULL columns that defaulted + to 0, the maxes are nullable and stayed NULL. + + The ISNULL inside each aggregate is not decoration. Without + it, SUM() and MAX() would skip NULLs on those versions and + SQL Server would warn about it on every run. Feeding them + zeros keeps them quiet, and the CASE — not the aggregate — + decides whether the answer is a value or a NULL. + */ + total_grant_mb = + CASE + WHEN @has_memory_grants = 1 + THEN SUM(ISNULL(s.max_grant_kb, 0)) / 1024.0 + ELSE 0 + END, + total_used_grant_mb = + CASE + WHEN @has_memory_grants = 1 + THEN SUM(ISNULL(s.max_used_grant_kb, 0)) / 1024.0 + ELSE 0 + END, + total_spills = + CASE + WHEN @has_spills = 1 + THEN SUM(ISNULL(s.total_spills, 0)) + ELSE 0 + END, + max_grant_mb = + CASE + WHEN @has_memory_grants = 1 + THEN MAX(ISNULL(s.max_grant_kb, 0)) / 1024.0 + END, + max_used_grant_mb = + CASE + WHEN @has_memory_grants = 1 + THEN MAX(ISNULL(s.max_used_grant_kb, 0)) / 1024.0 + END, + max_spills = + CASE + WHEN @has_spills = 1 + THEN MAX(ISNULL(s.max_spills, 0)) + END, + max_dop = MAX(s.max_dop), + min_rows = MIN(s.min_rows), + max_rows = MAX(s.max_rows), + min_cpu_ms = MIN(s.min_worker_time) / 1000.0, + max_cpu_ms = MAX(s.max_worker_time) / 1000.0, + min_physical_reads = MIN(s.min_physical_reads), + max_physical_reads = MAX(s.max_physical_reads), + min_duration_ms = MIN(s.min_elapsed_time) / 1000.0, + max_duration_ms = MAX(s.max_elapsed_time) / 1000.0, + oldest_plan_creation = MIN(s.creation_time), + newest_plan_creation = MAX(s.creation_time), + last_execution_time = MAX(s.last_execution_time) + FROM #dm_exec_query_stats AS s + /* @minimum_execution_count is enforced ONLY in the HAVING + SUM(execution_count) below — applying it per-row here + filtered out individual plans whose single-plan execution_count + was below the floor but whose group total was above it + (think: a recompile-heavy query with many plans each run a + few times that add up to a lot). Same reasoning applies to + the procedure / function / trigger paths further down. + The database filters were already applied when + #dm_exec_query_stats was populated. */ + WHERE s.query_hash <> 0x0000000000000000 + AND s.creation_time >= ISNULL(@start_date, s.creation_time) + AND s.creation_time < ISNULL(@end_date, DATEADD(DAY, 1, s.creation_time)) + GROUP BY + s.query_hash + HAVING + SUM(s.execution_count) >= @minimum_execution_count + ) AS totals + JOIN + ( + SELECT + s.query_hash, + s.database_id, + s.sql_handle, + s.plan_handle, + s.statement_start_offset, + s.statement_end_offset, + /* + Ranked after the WHERE clause, so the winner is the most + executed plan among the rows the caller actually asked for, + not the most executed plan in the entire cache. The filters + below have to stay in step with the ones above, or the winner + could come from a row the totals never counted. + */ + n = + ROW_NUMBER() OVER + ( + PARTITION BY + s.query_hash + ORDER BY + s.execution_count DESC + ) + FROM #dm_exec_query_stats AS s + WHERE s.query_hash <> 0x0000000000000000 + AND s.creation_time >= ISNULL(@start_date, s.creation_time) + AND s.creation_time < ISNULL(@end_date, DATEADD(DAY, 1, s.creation_time)) + ) AS winner + ON winner.query_hash = totals.query_hash + AND winner.n = 1 + OPTION(RECOMPILE, MAXDOP 1); IF @debug = 1 BEGIN @@ -1263,35 +1566,6 @@ OPTION(RECOMPILE, MAXDOP 1);'; RAISERROR(N'Statement query_hash groups collected: %I64d', 0, 1, @stmt_count) WITH NOWAIT; END; - /* - Fix up sample handles and offsets so they come from - the same plan row. MAX() in the GROUP BY can mismatch - a sql_handle from one plan with offsets from another, - producing clipped or blank query text. - */ - UPDATE - qs - SET - qs.sample_sql_handle = x.sql_handle, - qs.sample_plan_handle = x.plan_handle, - qs.sample_statement_start = x.statement_start_offset, - qs.sample_statement_end = x.statement_end_offset - FROM #query_stats AS qs - CROSS APPLY - ( - SELECT TOP (1) - dqs.sql_handle, - dqs.plan_handle, - dqs.statement_start_offset, - dqs.statement_end_offset - FROM sys.dm_exec_query_stats AS dqs - WHERE dqs.query_hash = qs.query_hash - ORDER BY - dqs.execution_count DESC - ) AS x - WHERE qs.query_type = 'Statement' - OPTION(RECOMPILE); - /* Link statement-level rows to their parent procedure via sql_handle in dm_exec_procedure_stats. @@ -1312,120 +1586,106 @@ OPTION(RECOMPILE, MAXDOP 1);'; /* ╔══════════════════════════════════════════════════╗ - ║ Step 1b: Collect procedure-level stats ║ - ║ (sys.dm_exec_procedure_stats) ║ + ║ Step 1b: Collect object-level stats ║ + ║ (procedures, functions, and triggers) ║ ╚══════════════════════════════════════════════════╝ + + These three DMVs are materialized for the same reason + sys.dm_exec_query_stats is, plus one that is specific to the + totals + winner shape below. + + A DMV is not a table and gets no statement-level snapshot. Reading + one twice in a single statement is two independent reads of live + plan cache memory, and the optimizer does exactly that here: the + totals side and the winner side each get their own scan operator. + If a plan is evicted between the two reads — on a churning cache, + which is the situation this proc exists to diagnose — the object + appears in totals, is missing from winner, and the inner join drops + it from the report entirely. No error, no warning, just a + stored procedure that quietly stops existing. + + A temp table can be read twice safely, because it holds still. So + read each DMV exactly once, up front, and let both sides of the + join work from the same snapshot. + + The three DMVs are shaped identically across every column used here, + so one staging table and one aggregate serve all of them; query_type + keeps them apart. The row filters are applied on the way in, which + is also why the totals and winner queries below need no WHERE clause + of their own — there is no pair of predicate lists that can drift + apart. + */ + CREATE TABLE + #dm_exec_object_stats + ( + query_type varchar(20) NOT NULL, + database_id integer NULL, + object_id integer NULL, + plan_handle varbinary(64) NULL, + sql_handle varbinary(64) NULL, + execution_count bigint NULL, + total_worker_time bigint NULL, + total_elapsed_time bigint NULL, + total_logical_reads bigint NULL, + total_logical_writes bigint NULL, + total_physical_reads bigint NULL, + cached_time datetime NULL, + last_execution_time datetime NULL + ); + + /* + @minimum_execution_count is enforced ONLY in the HAVING + SUM(execution_count) below, never as a per-row filter here — + applying it per row would drop individual plans whose single-plan + execution_count was below the floor but whose group total was above + it (a recompile-heavy object with many plans, each run a few times, + that add up to a lot). Same reasoning as the statement path. */ INSERT - #query_stats + #dm_exec_object_stats WITH (TABLOCK) ( query_type, - database_name, - object_name, - plan_count, - total_executions, - total_cpu_ms, - total_duration_ms, + database_id, + object_id, + plan_handle, + sql_handle, + execution_count, + total_worker_time, + total_elapsed_time, total_logical_reads, total_logical_writes, total_physical_reads, - oldest_plan_creation, - newest_plan_creation, - last_execution_time, - sample_sql_handle, - sample_plan_handle + cached_time, + last_execution_time ) - /* - sample_sql_handle and sample_plan_handle previously used - MAX(ps.sql_handle) and MAX(ps.plan_handle) — each picked the - lexicographic max independently, so the two values could come - from different plan rows and produce a mismatched text/plan - pair when retrieved downstream. ROW_NUMBER() OVER - (PARTITION BY database_id, object_id ORDER BY execution_count - DESC) in a derived table, then MAX(CASE WHEN n = 1 THEN ...) - in the outer aggregate, pulls both handles from the SAME winner - row. Single DMV scan + one sort + one aggregate — much lighter - than CROSS APPLY-ing the DMV per group, which nested-loops - poorly on busy servers. - */ SELECT query_type = 'Procedure', - database_name = DB_NAME(r.database_id), - object_name = OBJECT_SCHEMA_NAME(r.object_id, r.database_id) + N'.' + OBJECT_NAME(r.object_id, r.database_id), - plan_count = COUNT_BIG(DISTINCT r.plan_handle), - total_executions = SUM(r.execution_count), - total_cpu_ms = SUM(r.total_worker_time) / 1000.0, - total_duration_ms = SUM(r.total_elapsed_time) / 1000.0, - total_logical_reads = SUM(r.total_logical_reads), - total_logical_writes = SUM(r.total_logical_writes), - total_physical_reads = SUM(r.total_physical_reads), - oldest_plan_creation = MIN(r.cached_time), - newest_plan_creation = MAX(r.cached_time), - last_execution_time = MAX(r.last_execution_time), - sample_sql_handle = MAX(CASE WHEN r.n = 1 THEN r.sql_handle END), - sample_plan_handle = MAX(CASE WHEN r.n = 1 THEN r.plan_handle END) - FROM - ( - SELECT - ps.database_id, - ps.object_id, - ps.plan_handle, - ps.sql_handle, - ps.execution_count, - ps.total_worker_time, - ps.total_elapsed_time, - ps.total_logical_reads, - ps.total_logical_writes, - ps.total_physical_reads, - ps.cached_time, - ps.last_execution_time, - n = - ROW_NUMBER() OVER - ( - PARTITION BY - ps.database_id, - ps.object_id - ORDER BY - ps.execution_count DESC - ) - FROM sys.dm_exec_procedure_stats AS ps - /* See Statement path comment re: why @minimum_execution_count - is HAVING-only rather than a per-row pre-filter. */ - WHERE ps.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END - AND ps.database_id < 32761 - AND ps.database_id = ISNULL(@database_id, ps.database_id) - AND ps.cached_time >= ISNULL(@start_date, ps.cached_time) - AND ps.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ps.cached_time)) - ) AS r - GROUP BY - r.database_id, - r.object_id - HAVING - SUM(r.execution_count) >= @minimum_execution_count + ps.database_id, + ps.object_id, + ps.plan_handle, + ps.sql_handle, + ps.execution_count, + ps.total_worker_time, + ps.total_elapsed_time, + ps.total_logical_reads, + ps.total_logical_writes, + ps.total_physical_reads, + ps.cached_time, + ps.last_execution_time + FROM sys.dm_exec_procedure_stats AS ps + WHERE ps.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END + AND ps.database_id < 32761 + AND ps.database_id = ISNULL(@database_id, ps.database_id) + AND ps.cached_time >= ISNULL(@start_date, ps.cached_time) + AND ps.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ps.cached_time)) OPTION(RECOMPILE, MAXDOP 1); - IF @debug = 1 - BEGIN - DECLARE - @proc_count bigint; - - SELECT - @proc_count = COUNT_BIG(*) - FROM #query_stats AS qs - WHERE qs.query_type = 'Procedure'; - - RAISERROR(N'Procedure objects collected: %I64d', 0, 1, @proc_count) WITH NOWAIT; - END; - /* - ╔══════════════════════════════════════════════════╗ - ║ Step 1c: Collect function-level stats ║ - ║ (sys.dm_exec_function_stats) ║ - ╚══════════════════════════════════════════════════╝ - - dm_exec_function_stats is available starting SQL Server 2016. + sys.dm_exec_function_stats is available starting SQL Server 2016, so + it has to be referenced from dynamic SQL to keep this procedure + compiling on older builds. */ IF EXISTS ( @@ -1439,111 +1699,107 @@ OPTION(RECOMPILE, MAXDOP 1);'; SELECT @sql = N' INSERT - #query_stats + #dm_exec_object_stats WITH (TABLOCK) ( query_type, - database_name, - object_name, - plan_count, - total_executions, - total_cpu_ms, - total_duration_ms, + database_id, + object_id, + plan_handle, + sql_handle, + execution_count, + total_worker_time, + total_elapsed_time, total_logical_reads, total_logical_writes, total_physical_reads, - oldest_plan_creation, - newest_plan_creation, - last_execution_time, - sample_sql_handle, - sample_plan_handle + cached_time, + last_execution_time ) -/* Same ROW_NUMBER + derived-table pattern as procedure path. */ SELECT query_type = ''Function'', - database_name = DB_NAME(r.database_id), - object_name = OBJECT_SCHEMA_NAME(r.object_id, r.database_id) + N''.'' + OBJECT_NAME(r.object_id, r.database_id), - plan_count = COUNT_BIG(DISTINCT r.plan_handle), - total_executions = SUM(r.execution_count), - total_cpu_ms = SUM(r.total_worker_time) / 1000.0, - total_duration_ms = SUM(r.total_elapsed_time) / 1000.0, - total_logical_reads = SUM(r.total_logical_reads), - total_logical_writes = SUM(r.total_logical_writes), - total_physical_reads = SUM(r.total_physical_reads), - oldest_plan_creation = MIN(r.cached_time), - newest_plan_creation = MAX(r.cached_time), - last_execution_time = MAX(r.last_execution_time), - sample_sql_handle = MAX(CASE WHEN r.n = 1 THEN r.sql_handle END), - sample_plan_handle = MAX(CASE WHEN r.n = 1 THEN r.plan_handle END) -FROM -( - SELECT - fs.database_id, - fs.object_id, - fs.plan_handle, - fs.sql_handle, - fs.execution_count, - fs.total_worker_time, - fs.total_elapsed_time, - fs.total_logical_reads, - fs.total_logical_writes, - fs.total_physical_reads, - fs.cached_time, - fs.last_execution_time, - n = - ROW_NUMBER() OVER - ( - PARTITION BY - fs.database_id, - fs.object_id - ORDER BY - fs.execution_count DESC - ) - FROM sys.dm_exec_function_stats AS fs - /* See Statement path comment re: why @minimum_execution_count - is HAVING-only rather than a per-row pre-filter. */ - WHERE fs.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END - AND fs.database_id < 32761 - AND fs.database_id = ISNULL(@database_id, fs.database_id) - AND fs.cached_time >= ISNULL(@start_date, fs.cached_time) - AND fs.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, fs.cached_time)) -) AS r -GROUP BY - r.database_id, - r.object_id -HAVING - SUM(r.execution_count) >= @minimum_execution_count + fs.database_id, + fs.object_id, + fs.plan_handle, + fs.sql_handle, + fs.execution_count, + fs.total_worker_time, + fs.total_elapsed_time, + fs.total_logical_reads, + fs.total_logical_writes, + fs.total_physical_reads, + fs.cached_time, + fs.last_execution_time +FROM sys.dm_exec_function_stats AS fs +WHERE fs.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END +AND fs.database_id < 32761 +AND fs.database_id = ISNULL(@database_id, fs.database_id) +AND fs.cached_time >= ISNULL(@start_date, fs.cached_time) +AND fs.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, fs.cached_time)) OPTION(RECOMPILE, MAXDOP 1);'; + IF @debug = 1 + BEGIN + RAISERROR(N'Function stats materialization SQL:', 0, 1) WITH NOWAIT; + RAISERROR(N'%s', 0, 1, @sql) WITH NOWAIT; + END; + EXECUTE sys.sp_executesql @sql, - N'@minimum_execution_count bigint, @database_id integer, @ignore_system_databases bit, @start_date datetime, @end_date datetime', - @minimum_execution_count, + N'@database_id integer, @ignore_system_databases bit, @start_date datetime, @end_date datetime', @database_id, @ignore_system_databases, @start_date, @end_date; - - IF @debug = 1 - BEGIN - DECLARE - @func_count bigint; - - SELECT - @func_count = COUNT_BIG(*) - FROM #query_stats AS qs - WHERE qs.query_type = 'Function'; - - RAISERROR(N'Function objects collected: %I64d', 0, 1, @func_count) WITH NOWAIT; - END; END; + INSERT + #dm_exec_object_stats + WITH + (TABLOCK) + ( + query_type, + database_id, + object_id, + plan_handle, + sql_handle, + execution_count, + total_worker_time, + total_elapsed_time, + total_logical_reads, + total_logical_writes, + total_physical_reads, + cached_time, + last_execution_time + ) + SELECT + query_type = 'Trigger', + ts.database_id, + ts.object_id, + ts.plan_handle, + ts.sql_handle, + ts.execution_count, + ts.total_worker_time, + ts.total_elapsed_time, + ts.total_logical_reads, + ts.total_logical_writes, + ts.total_physical_reads, + ts.cached_time, + ts.last_execution_time + FROM sys.dm_exec_trigger_stats AS ts + WHERE ts.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END + AND ts.database_id < 32761 + AND ts.database_id = ISNULL(@database_id, ts.database_id) + AND ts.cached_time >= ISNULL(@start_date, ts.cached_time) + AND ts.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ts.cached_time)) + OPTION(RECOMPILE, MAXDOP 1); + /* - ╔══════════════════════════════════════════════════╗ - ║ Step 1d: Collect trigger-level stats ║ - ║ (sys.dm_exec_trigger_stats) ║ - ╚══════════════════════════════════════════════════╝ + One aggregate for all three object types. Same shape as the + statement path: totals rolls the object up, winner picks the + most-executed plan to represent it, and both handles come from that + one row so the text and the plan always describe the same plan. */ INSERT #query_stats @@ -1566,73 +1822,100 @@ OPTION(RECOMPILE, MAXDOP 1);'; sample_sql_handle, sample_plan_handle ) - /* Same ROW_NUMBER + derived-table pattern as procedure/function paths. */ SELECT - query_type = 'Trigger', - database_name = DB_NAME(r.database_id), - object_name = OBJECT_SCHEMA_NAME(r.object_id, r.database_id) + N'.' + OBJECT_NAME(r.object_id, r.database_id), - plan_count = COUNT_BIG(DISTINCT r.plan_handle), - total_executions = SUM(r.execution_count), - total_cpu_ms = SUM(r.total_worker_time) / 1000.0, - total_duration_ms = SUM(r.total_elapsed_time) / 1000.0, - total_logical_reads = SUM(r.total_logical_reads), - total_logical_writes = SUM(r.total_logical_writes), - total_physical_reads = SUM(r.total_physical_reads), - oldest_plan_creation = MIN(r.cached_time), - newest_plan_creation = MAX(r.cached_time), - last_execution_time = MAX(r.last_execution_time), - sample_sql_handle = MAX(CASE WHEN r.n = 1 THEN r.sql_handle END), - sample_plan_handle = MAX(CASE WHEN r.n = 1 THEN r.plan_handle END) + query_type = totals.query_type, + database_name = DB_NAME(totals.database_id), + object_name = + OBJECT_SCHEMA_NAME(totals.object_id, totals.database_id) + + N'.' + + OBJECT_NAME(totals.object_id, totals.database_id), + plan_count = totals.plan_count, + total_executions = totals.total_executions, + total_cpu_ms = totals.total_cpu_ms, + total_duration_ms = totals.total_duration_ms, + total_logical_reads = totals.total_logical_reads, + total_logical_writes = totals.total_logical_writes, + total_physical_reads = totals.total_physical_reads, + oldest_plan_creation = totals.oldest_plan_creation, + newest_plan_creation = totals.newest_plan_creation, + last_execution_time = totals.last_execution_time, + sample_sql_handle = winner.sql_handle, + sample_plan_handle = winner.plan_handle FROM ( SELECT - ts.database_id, - ts.object_id, - ts.plan_handle, - ts.sql_handle, - ts.execution_count, - ts.total_worker_time, - ts.total_elapsed_time, - ts.total_logical_reads, - ts.total_logical_writes, - ts.total_physical_reads, - ts.cached_time, - ts.last_execution_time, + os.query_type, + os.database_id, + os.object_id, + plan_count = COUNT_BIG(DISTINCT os.plan_handle), + total_executions = SUM(os.execution_count), + total_cpu_ms = SUM(os.total_worker_time) / 1000.0, + total_duration_ms = SUM(os.total_elapsed_time) / 1000.0, + total_logical_reads = SUM(os.total_logical_reads), + total_logical_writes = SUM(os.total_logical_writes), + total_physical_reads = SUM(os.total_physical_reads), + oldest_plan_creation = MIN(os.cached_time), + newest_plan_creation = MAX(os.cached_time), + last_execution_time = MAX(os.last_execution_time) + FROM #dm_exec_object_stats AS os + GROUP BY + os.query_type, + os.database_id, + os.object_id + HAVING + SUM(os.execution_count) >= @minimum_execution_count + ) AS totals + JOIN + ( + SELECT + os.query_type, + os.database_id, + os.object_id, + os.sql_handle, + os.plan_handle, n = ROW_NUMBER() OVER ( PARTITION BY - ts.database_id, - ts.object_id + os.query_type, + os.database_id, + os.object_id ORDER BY - ts.execution_count DESC + os.execution_count DESC ) - FROM sys.dm_exec_trigger_stats AS ts - /* See Statement path comment re: why @minimum_execution_count - is HAVING-only rather than a per-row pre-filter. */ - WHERE ts.database_id > CASE WHEN @ignore_system_databases = 1 THEN 4 ELSE 0 END - AND ts.database_id < 32761 - AND ts.database_id = ISNULL(@database_id, ts.database_id) - AND ts.cached_time >= ISNULL(@start_date, ts.cached_time) - AND ts.cached_time < ISNULL(@end_date, DATEADD(DAY, 1, ts.cached_time)) - ) AS r - GROUP BY - r.database_id, - r.object_id - HAVING - SUM(r.execution_count) >= @minimum_execution_count + FROM #dm_exec_object_stats AS os + ) AS winner + ON winner.query_type = totals.query_type + AND winner.database_id = totals.database_id + AND winner.object_id = totals.object_id + AND winner.n = 1 OPTION(RECOMPILE, MAXDOP 1); IF @debug = 1 BEGIN DECLARE + @proc_count bigint, + @func_count bigint, @trig_count bigint; + /* + ISNULL because SUM over ZERO rows returns NULL, not 0 — the + ELSE 0 only guards against NULL inputs, not against #query_stats + being empty, which it is whenever the filters match nothing. + Without this these print "(null)" where the three separate + COUNT_BIG(*) reads they replaced printed "0". + */ SELECT - @trig_count = COUNT_BIG(*) - FROM #query_stats AS qs - WHERE qs.query_type = 'Trigger'; + @proc_count = + ISNULL(SUM(CASE WHEN qs.query_type = 'Procedure' THEN 1 ELSE 0 END), 0), + @func_count = + ISNULL(SUM(CASE WHEN qs.query_type = 'Function' THEN 1 ELSE 0 END), 0), + @trig_count = + ISNULL(SUM(CASE WHEN qs.query_type = 'Trigger' THEN 1 ELSE 0 END), 0) + FROM #query_stats AS qs; + RAISERROR(N'Procedure objects collected: %I64d', 0, 1, @proc_count) WITH NOWAIT; + RAISERROR(N'Function objects collected: %I64d', 0, 1, @func_count) WITH NOWAIT; RAISERROR(N'Trigger objects collected: %I64d', 0, 1, @trig_count) WITH NOWAIT; END; diff --git a/sp_QuickieStore/README.md b/sp_QuickieStore/README.md index 521a891c..776cac6b 100644 --- a/sp_QuickieStore/README.md +++ b/sp_QuickieStore/README.md @@ -28,14 +28,14 @@ Use the `@expert_mode` parameter to return additional details. | parameter_name | data_type | description | valid_inputs | defaults | |-----------------------------------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------| | @database_name | sysname | the name of the database you want to look at query store in | a database name with query store enabled | NULL; current database name if NULL | -| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg prefix also accepted, e.g. "avg cpu") | cpu | +| @sort_order | varchar | the runtime metric you want to prioritize results by | cpu, logical reads, physical reads, writes, duration, memory, tempdb, executions, recent, plan count by hashes, cpu waits, lock waits, locks waits, latch waits, latches waits, buffer latch waits, buffer latches waits, buffer io waits, log waits, log io waits, network waits, network io waits, parallel waits, parallelism waits, memory waits, total waits, rows, total cpu, total logical reads, total physical reads, total writes, total duration, total memory, total tempdb, total rows (avg/average prefix also accepted, e.g. avg cpu, average duration) | cpu | | @top | bigint | the number of queries you want to pull back | a positive integer between 1 and 9,223,372,036,854,775,807 | 10 | | @start_date | datetimeoffset | the begin date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | the last seven days | | @end_date | datetimeoffset | the end date of your search, will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL | | @timezone | sysname | user specified time zone to override dates displayed in results | SELECT tzi.* FROM sys.time_zone_info AS tzi; | NULL | | @execution_count | bigint | the minimum number of executions a query must have | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL | | @duration_ms | bigint | the minimum duration a query must have to show up in results | a positive integer between 1 and 9,223,372,036,854,775,807 | NULL | -| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception) | regular, aborted, exception | NULL | +| @execution_type_desc | nvarchar | the type of execution you want to filter by (regular, aborted, exception, failed); failed bundles aborted and exception | regular, aborted, exception, failed | NULL | | @procedure_schema | sysname | the schema of the procedure you're searching for | a valid schema in your database | NULL; dbo if NULL and procedure name is not NULL | | @procedure_name | sysname | the name of the programmable object you're searching for | a valid programmable object in your database, can use wildcards | NULL | | @include_plan_ids | nvarchar | a list of plan ids to search for | a string; comma separated for multiple ids | NULL | @@ -72,9 +72,16 @@ Use the `@expert_mode` parameter to return additional details. | @regression_baseline_end_date | datetimeoffset | the end date of the baseline that you are checking for regressions against (if any), will be converted to UTC internally | January 1, 1753, through December 31, 9999 | NULL; One week after @regression_baseline_start_date if that is specified | | @regression_comparator | varchar | what difference to use ('relative' or 'absolute') when comparing @sort_order's metric for the normal time period with any regression time period. | relative, absolute | NULL; absolute if @regression_baseline_start_date is specified | | @regression_direction | varchar | when comparing against any regression baseline, what do you want the results sorted by ('magnitude', 'improved', or 'regressed')? | regressed, worse, improved, better, magnitude, absolute, whatever | NULL; regressed if @regression_baseline_start_date is specified | -| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash | 0 or 1 | 0 | +| @include_query_hash_totals | bit | will add an additional column to final output with total resource usage by query hash, may be skewed by query_hash and query_plan_hash bugs with forced plans/plan guides | 0 or 1 | 0 | | @find_high_impact | bit | finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions | 0 or 1 | 0 | +| @primary_window | nvarchar | with @find_high_impact, restricts results to queries whose majority activity is in this window (business, off-hours, or weekend) | business, off-hours, or weekend (any unambiguous prefix works: b, biz, off, overnight, w, wknd, etc.) | NULL | +| @find_parameter_sensitive | bit | finds plan shapes whose runtime metrics swing wildly across executions of the same plan: classic parameter sensitivity | 0 or 1 | 0 | | @include_maintenance | bit | Set this bit to 1 to add maintenance operations such as index creation to the result set | 0 or 1 | 0 | +| @log_to_table | bit | enable logging to permanent tables instead of returning results | 0 or 1 | 0 | +| @log_database_name | sysname | database to store logging tables | a valid database name | NULL; current database | +| @log_schema_name | sysname | schema to store logging tables | a valid schema name | NULL; dbo | +| @log_table_name_prefix | sysname | prefix for all logging table names | a valid identifier | QuickieStore | +| @log_retention_days | integer | days of data to retain, 0 = keep indefinitely | a positive integer, or 0 | 30 | | @help | bit | how you got here | 0 or 1 | 0 | | @debug | bit | prints dynamic sql, statement length, parameter and variable values, and raw temp table contents | 0 or 1 | 0 | | @troubleshoot_performance | bit | set statistics xml on for queries against views | 0 or 1 | 0 | @@ -114,6 +121,16 @@ EXECUTE dbo.sp_QuickieStore @regression_baseline_end_date = '2025-01-08', @regression_direction = 'regressed'; +-- Find the vital few queries consuming disproportionate resources +EXECUTE dbo.sp_QuickieStore + @find_high_impact = 1; + +-- Find parameter sensitive plan shapes: same plan, wildly different runtimes. +-- Results are one row per query_hash + query_plan_hash, ranked by how +-- volatile the @sort_order metric is (coefficient of variation) +EXECUTE dbo.sp_QuickieStore + @find_parameter_sensitive = 1; + -- Expert mode for additional details EXECUTE dbo.sp_QuickieStore @expert_mode = 1; diff --git a/sp_QuickieStore/sp_QuickieStore.sql b/sp_QuickieStore/sp_QuickieStore.sql index 7fb16fb5..4fe20faf 100644 --- a/sp_QuickieStore/sp_QuickieStore.sql +++ b/sp_QuickieStore/sp_QuickieStore.sql @@ -55,7 +55,7 @@ ALTER PROCEDURE dbo.sp_QuickieStore ( @database_name sysname = NULL, /*the name of the database you want to look at query store in*/ - @sort_order varchar(20) = 'cpu', /*the runtime metric you want to prioritize results by*/ + @sort_order varchar(50) = 'cpu', /*the runtime metric you want to prioritize results by; varchar(50) so the documented 'average '/'avg ' prefix forms (e.g. 'average buffer latches waits') aren't truncated before normalization and silently fall back to cpu*/ @top bigint = 10, /*the number of queries you want to pull back*/ @start_date datetimeoffset(7) = NULL, /*the begin date of your search, will be converted to UTC internally*/ @end_date datetimeoffset(7) = NULL, /*the end date of your search, will be converted to UTC internally*/ @@ -75,9 +75,9 @@ ALTER PROCEDURE @ignore_query_hashes nvarchar(4000) = NULL, /*a list of query hashes to ignore*/ @ignore_plan_hashes nvarchar(4000) = NULL, /*a list of query plan hashes to ignore*/ @ignore_sql_handles nvarchar(4000) = NULL, /*a list of sql handles to ignore*/ - @query_text_search nvarchar(4000) = NULL, /*query text to search for*/ - @query_text_search_not nvarchar(4000) = NULL, /*query text to exclude*/ - @escape_brackets bit = 0, /*Set this bit to 1 to search for query text containing square brackets (common in .NET Entity Framework and other ORM queries)*/ + @query_text_search nvarchar(4000) = NULL, /*query text to search for; matched with LIKE, so _ % [ ] are wildcards unless @escape_brackets = 1*/ + @query_text_search_not nvarchar(4000) = NULL, /*query text to exclude; matched with LIKE, so _ % [ ] are wildcards unless @escape_brackets = 1*/ + @escape_brackets bit = 0, /*Set this bit to 1 to treat [, ], and _ as literals (not LIKE wildcards) in the query text search; helpful for .NET Entity Framework and other ORM queries. Percent (%) stays a wildcard.*/ @escape_character nchar(1) = N'\', /*Sets the ESCAPE character for special character searches, defaults to the SQL standard backslash (\) character*/ @only_queries_with_hints bit = 0, /*Set this bit to 1 to retrieve only queries with query hints*/ @only_queries_with_feedback bit = 0, /*Set this bit to 1 to retrieve only queries with query feedback*/ @@ -103,6 +103,7 @@ ALTER PROCEDURE @include_maintenance bit = 0, /*Set this bit to 1 to add maintenance operations such as index creation to the result set*/ @find_high_impact bit = 0, /*finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions*/ @primary_window nvarchar(20) = NULL, /*with @find_high_impact, restricts results to queries whose majority activity is in this window: business, off-hours, or weekend*/ + @find_parameter_sensitive bit = 0, /*finds plan shapes whose runtime metrics swing wildly across executions of the same plan: classic parameter sensitivity*/ @help bit = 0, /*return available parameter details, etc.*/ @debug bit = 0, /*prints dynamic sql, statement length, parameter and variable values, and raw temp table contents*/ @troubleshoot_performance bit = 0, /*set statistics xml on for queries against views*/ @@ -127,8 +128,8 @@ BEGIN TRY These are for your outputs. */ SELECT - @version = '6.7', - @version_date = '20260701'; + @version = '6.8', + @version_date = '20260801'; /* Helpful section! For help. @@ -178,9 +179,9 @@ BEGIN WHEN N'@ignore_query_hashes' THEN 'a list of query hashes to ignore' WHEN N'@ignore_plan_hashes' THEN 'a list of query plan hashes to ignore' WHEN N'@ignore_sql_handles' THEN 'a list of sql handles to ignore' - WHEN N'@query_text_search' THEN 'query text to search for' - WHEN N'@query_text_search_not' THEN 'query text to exclude' - WHEN N'@escape_brackets' THEN 'Set this bit to 1 to search for query text containing square brackets (common in .NET Entity Framework and other ORM queries)' + WHEN N'@query_text_search' THEN 'query text to search for; matched with LIKE, so _ % [ ] are treated as wildcards unless you set @escape_brackets = 1' + WHEN N'@query_text_search_not' THEN 'query text to exclude; matched with LIKE, so _ % [ ] are treated as wildcards unless you set @escape_brackets = 1' + WHEN N'@escape_brackets' THEN 'Set this bit to 1 to treat [, ], and _ as literals (not LIKE wildcards) in @query_text_search / @query_text_search_not; helpful for .NET Entity Framework and other ORM query text. Percent (%) stays a wildcard.' WHEN N'@escape_character' THEN 'Sets the ESCAPE character for special character searches, defaults to the SQL standard backslash (\) character' WHEN N'@only_queries_with_hints' THEN 'only return queries with query hints' WHEN N'@only_queries_with_feedback' THEN 'only return queries with query feedback' @@ -206,6 +207,7 @@ BEGIN WHEN N'@include_maintenance' THEN N'Set this bit to 1 to add maintenance operations such as index creation to the result set' WHEN N'@find_high_impact' THEN N'finds the vital few queries consuming disproportionate resources across cpu, duration, reads, writes, memory, and executions' WHEN N'@primary_window' THEN N'with @find_high_impact, restricts results to queries whose majority activity is in this window (business, off-hours, or weekend)' + WHEN N'@find_parameter_sensitive' THEN N'finds plan shapes whose runtime metrics swing wildly across executions of the same plan: classic parameter sensitivity' WHEN N'@help' THEN 'how you got here' WHEN N'@debug' THEN 'prints dynamic sql, statement length, parameter and variable values, and raw temp table contents' WHEN N'@troubleshoot_performance' THEN 'set statistics xml on for queries against views' @@ -269,6 +271,7 @@ BEGIN WHEN N'@include_maintenance' THEN N'0 or 1' WHEN N'@find_high_impact' THEN N'0 or 1' WHEN N'@primary_window' THEN N'business, off-hours, or weekend (any unambiguous prefix works: b, biz, off, overnight, w, wknd, etc.)' + WHEN N'@find_parameter_sensitive' THEN N'0 or 1' WHEN N'@help' THEN '0 or 1' WHEN N'@debug' THEN '0 or 1' WHEN N'@troubleshoot_performance' THEN '0 or 1' @@ -332,6 +335,7 @@ BEGIN WHEN N'@include_maintenance' THEN N'0' WHEN N'@find_high_impact' THEN N'0' WHEN N'@primary_window' THEN N'NULL' + WHEN N'@find_parameter_sensitive' THEN N'0' WHEN N'@help' THEN '0' WHEN N'@debug' THEN '0' WHEN N'@troubleshoot_performance' THEN '0' @@ -415,6 +419,9 @@ BEGIN high_impact_columns = 'when using @find_high_impact = 1, the result set contains these columns:' UNION ALL SELECT REPLICATE('-', 100) UNION ALL + SELECT 'this mode honors @top, @sort_order (as a tiebreak), @work_start/@work_end, and @primary_window; the other' UNION ALL + SELECT ' filter parameters (@procedure_name, @query_text_search, include/ignore lists, etc.) do not apply here.' UNION ALL + SELECT REPLICATE('-', 100) UNION ALL SELECT 'database_name: the database being analyzed' UNION ALL SELECT 'start_date, end_date: the time window analyzed (UTC)' UNION ALL SELECT REPLICATE('-', 100) UNION ALL @@ -472,6 +479,69 @@ BEGIN SELECT ' For flat workloads: consider forced parameterization, look for missing schema prefixes,' UNION ALL SELECT ' temp table patterns causing recompilation, or RECOMPILE hints generating unique plans.'; + /* + Parameter Sensitive column guide + */ + SELECT + parameter_sensitive_columns = + 'when using @find_parameter_sensitive = 1, results are one row per plan shape (query_hash + query_plan_hash) with these columns:' UNION ALL + SELECT REPLICATE('-', 100) UNION ALL + SELECT 'a plan shape groups every plan_id that compiled to the same query_plan_hash for the same query_hash,' UNION ALL + SELECT ' so recompiles of the same plan are analyzed together. Wild metric swings within one shape mean' UNION ALL + SELECT ' the same plan is fast for some parameter values and slow for others: classic parameter sensitivity.' UNION ALL + SELECT ' Only regular executions feed the statistics; aborted and exception executions are counted separately.' UNION ALL + SELECT ' This mode honors @top, @sort_order, @execution_count, and @duration_ms; the other filter' UNION ALL + SELECT ' parameters (@procedure_name, @query_text_search, include/ignore lists, etc.) do not apply here.' UNION ALL + SELECT REPLICATE('-', 100) UNION ALL + SELECT 'database_name: the database being analyzed' UNION ALL + SELECT 'start_date, end_date: the time window analyzed' UNION ALL + SELECT 'object_name: the stored procedure, function, or trigger this query belongs to, or "Adhoc" for ad hoc SQL' UNION ALL + SELECT 'query_sql_text: representative query text (the most-executed query_id in this shape)' UNION ALL + SELECT 'query_plan: the most recently executed plan (XML) for this shape' UNION ALL + SELECT 'top_waits: top 3 Query Store wait categories for this shape (SQL 2017+ with wait stats enabled, omitted otherwise)' UNION ALL + SELECT 'query_hash, query_plan_hash: the grouping keys' UNION ALL + SELECT 'query_count, plan_count: distinct query_ids and plan_ids that make up this shape' UNION ALL + SELECT 'shapes_for_query_hash: how many distinct plan shapes this query_hash has overall.' UNION ALL + SELECT ' 1 shape with high volatility = one plan that cannot serve every parameter value.' UNION ALL + SELECT ' Multiple shapes = the optimizer also flips plans; check the other shapes'' averages in signals.' UNION ALL + SELECT 'query_id_list, plan_id_list: comma-separated IDs for drilling into Query Store views' UNION ALL + SELECT REPLICATE('-', 100) UNION ALL + SELECT 'ranked_on, volatility_score: results are ranked by the coefficient of variation (stdev / average) of the' UNION ALL + SELECT ' @sort_order metric (cpu, duration, physical reads, writes, memory, rows, tempdb; anything else = cpu).' UNION ALL + SELECT ' The stdev is properly combined across runtime stat intervals, weighted by executions, so one weird' UNION ALL + SELECT ' interval does not dominate. 0 = perfectly stable, 1 = the stdev equals the average, bigger = wilder.' UNION ALL + SELECT 'signals: rule-based reads on the swings. Nx means max is N times min for that metric' UNION ALL + SELECT ' (tiny mins are floored at 0.001 ms / 1 row first, so extreme ratios are lower bounds):' UNION ALL + SELECT ' parameter sensitive (cpu Nx, rows Nx) - big cpu swings while rows barely move. The plan does wildly' UNION ALL + SELECT ' different amounts of work for similar-sized results: the strongest sniffing signal.' UNION ALL + SELECT ' row driven (cpu Nx, rows Nx) - cpu swings track row count swings. Work scales with honest data volume,' UNION ALL + SELECT ' so this is probably not a parameter sensitivity problem.' UNION ALL + SELECT ' intermittent waits (duration Nx, cpu Nx) - duration swings without cpu swings: blocking, grants, or I/O,' UNION ALL + SELECT ' not parameter sensitivity. Check top_waits.' UNION ALL + SELECT ' memory grant swings (Nx) / tempdb swings (Nx) - grant or spill behavior varies between executions.' UNION ALL + SELECT ' timed out or cancelled N times / errored N times - aborted (client timeout/cancel) and exception executions.' UNION ALL + SELECT ' Timeouts on a volatile shape are often the bad-parameter executions themselves.' UNION ALL + SELECT ' psp variants involved (2022+) - Parameter Sensitive Plan optimization already dispatched variants here.' UNION ALL + SELECT ' other plan shapes exist (N, avg cpu X to Y ms) - the same query_hash compiled to other shapes whose' UNION ALL + SELECT ' averages diverge: the optimizer is flipping between good and bad plans.' UNION ALL + SELECT REPLICATE('-', 100) UNION ALL + SELECT 'total_executions, aborted_executions, exception_executions: execution counts for this shape in the window' UNION ALL + SELECT 'min/avg/max cpu, duration (ms) and rows, each with a volatility (coefficient of variation) column' UNION ALL + SELECT 'last_execution_time: the most recent regular execution of this shape in the time window' UNION ALL + SELECT 'variance_metrics: clickable XML rollup with min/avg/max/volatility for memory, physical reads, writes,' UNION ALL + SELECT ' and tempdb (2017+), plus max DOP. Click the column in SSMS to see the full breakdown.' UNION ALL + SELECT REPLICATE('-', 100) UNION ALL + SELECT 'what this mode cannot tell you: Query Store never records which parameter values produced the min or max,' UNION ALL + SELECT ' so this convicts the query, not the values. Compare compiled parameter values across the plan_ids in' UNION ALL + SELECT ' plan_id_list, or use sp_HumanEvents to catch live executions with runtime parameter values.' UNION ALL + SELECT REPLICATE('-', 100) UNION ALL + SELECT 'SHAPE VOLATILITY SUMMARY (separate result set, returned before the query details):' UNION ALL + SELECT 'total_shapes: how many plan shapes had regular executions in the time window' UNION ALL + SELECT 'shapes_past_floors: how many cleared the noise floors (executions and minimum cpu/duration)' UNION ALL + SELECT 'surfaced_shapes: how many made it into the detail result set after top-N ranking' UNION ALL + SELECT 'multi_shape_query_hashes: how many query_hashes compiled to more than one plan shape in the window' UNION ALL + SELECT 'ranked_on: which metric''s volatility ranked the results, from @sort_order'; + /* Limitations */ @@ -785,6 +855,41 @@ BEGIN RETURN; END; +/* +@find_parameter_sensitive is a takeover mode like @find_high_impact, +so the two of them can't be combined, and it can't be combined with +the things @find_high_impact can't be combined with either +*/ +IF +( + @find_parameter_sensitive = 1 +AND @find_high_impact = 1 +) +BEGIN + RAISERROR('@find_parameter_sensitive cannot be used with @find_high_impact. Pick one mode per run.', 11, 1) WITH NOWAIT; + RETURN; +END; + +IF +( + @find_parameter_sensitive = 1 +AND @get_all_databases = 1 +) +BEGIN + RAISERROR('@find_parameter_sensitive cannot be used with @get_all_databases. Run @find_parameter_sensitive against each database individually.', 11, 1) WITH NOWAIT; + RETURN; +END; + +IF +( + @log_to_table = 1 +AND @find_parameter_sensitive = 1 +) +BEGIN + RAISERROR('@log_to_table cannot be used with @find_parameter_sensitive. Run them separately.', 11, 1) WITH NOWAIT; + RETURN; +END; + /* @primary_window only applies to the @find_high_impact path, and must match one of the three bucket labels by case-insensitive prefix: b/o/w @@ -2005,6 +2110,37 @@ FROM ) WHERE v.parameter_value IS NOT NULL; +/* +Attempt at overloading procedure name so it can accept a schema.procedure or +db.schema.procedure pasted from results from other executions of sp_QuickieStore. +PARSENAME copes with bracketed ([dbo].[proc]) and unbracketed (dbo.proc) forms +alike; the old LIKE '[[]%].[[]%]' pattern only matched the fully bracketed +two-part form, so an unbracketed dbo.proc fell through and was later QUOTENAME'd +whole into a single mangled [dbo.proc] identifier that could never resolve. +This runs before the @database_name default below so a three-part paste can +supply its own database. +*/ +IF @procedure_name IS NOT NULL +AND @procedure_schema IS NULL +AND PARSENAME(@procedure_name, 2) IS NOT NULL +BEGIN + /* + A three-part db.schema.proc paste: adopt its database when the caller didn't + pass @database_name, rather than dropping it (which looked the procedure up + in the wrong database) or refusing to split it (a false 'not found'). + */ + IF PARSENAME(@procedure_name, 3) IS NOT NULL + AND @database_name IS NULL + BEGIN + SELECT + @database_name = PARSENAME(@procedure_name, 3); + END; + + SELECT + @procedure_schema = PARSENAME(@procedure_name, 2), + @procedure_name = PARSENAME(@procedure_name, 1); +END; + /* Try to be helpful by subbing in a database name if null */ @@ -2032,22 +2168,6 @@ BEGIN DB_NAME(); END; -/* -Attempt at overloading procedure name so it can -accept a [schema].[procedure] pasted from results -from other executions of sp_QuickieStore -*/ -IF -( - @procedure_name LIKE N'[[]%].[[]%]' - AND @procedure_schema IS NULL -) -BEGIN - SELECT - @procedure_schema = PARSENAME(@procedure_name, 2), - @procedure_name = PARSENAME(@procedure_name, 1); -END; - /* Variables for the variable gods */ @@ -2112,15 +2232,16 @@ DECLARE @temp_target_table nvarchar(100), @exist_or_not_exist nvarchar(20), /*Log to table stuff*/ - @log_table_runtime_stats sysname, - @log_table_compilation_stats sysname, - @log_table_resource_stats sysname, - @log_table_wait_stats_by_query sysname, - @log_table_wait_stats_total sysname, - @log_table_plan_feedback sysname, - @log_table_query_hints sysname, - @log_table_query_variants sysname, - @log_table_query_store_options sysname, + /* nvarchar(1024), not sysname: each holds a full 3-part quoted name (@log_database_schema + QUOTENAME(prefix_suffix)); sysname(128) truncated it, cutting the closing bracket on a long log database/schema name and breaking the generated CREATE/INSERT/DELETE. Matches @log_database_schema's own width. */ + @log_table_runtime_stats nvarchar(1024), + @log_table_compilation_stats nvarchar(1024), + @log_table_resource_stats nvarchar(1024), + @log_table_wait_stats_by_query nvarchar(1024), + @log_table_wait_stats_total nvarchar(1024), + @log_table_plan_feedback nvarchar(1024), + @log_table_query_hints nvarchar(1024), + @log_table_query_variants nvarchar(1024), + @log_table_query_store_options nvarchar(1024), @cleanup_date datetime2(7), @create_sql nvarchar(max), @insert_sql nvarchar(max), @@ -2950,10 +3071,11 @@ BEGIN x = CONVERT ( xml, + /* escape &, <, > before building the XML (innermost, so the tags we add stay literal); a legal database name like R&D otherwise makes CONVERT(xml, ...) throw error 9411 and aborts the procedure */ N'' + REPLACE ( - @include_databases, + REPLACE(REPLACE(REPLACE(@include_databases, N'&', N'&'), N'<', N'<'), N'>', N'>'), N',', N'' ) + @@ -2984,10 +3106,11 @@ BEGIN x = CONVERT ( xml, + /* escape &, <, > before building the XML (innermost, so the tags we add stay literal); a legal database name like R&D otherwise makes CONVERT(xml, ...) throw error 9411 and aborts the procedure */ N'' + REPLACE ( - @exclude_databases, + REPLACE(REPLACE(REPLACE(@exclude_databases, N'&', N'&'), N'<', N'<'), N'>', N'>'), N',', N'' ) + @@ -3445,6 +3568,13 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;', @sql_2022_views = 0, @ags_present = 0, @current_table = N'', + /* + Both reusable list splitters below carry WHERE ids.ids IS NOT NULL: a + leading, trailing, or doubled comma in a caller's list (e.g. '123,456,') + produces an empty node whose value is NULL, which otherwise flows + into the NOT NULL primary-key id/hash/handle columns and aborts the whole + procedure with Msg 515. The @include_databases splitter already guarded this. + */ @string_split_ints = N' SELECT DISTINCT ids = @@ -3488,6 +3618,7 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;', ) AS ids CROSS APPLY ids.nodes(''x'') AS x (x) ) AS ids + WHERE ids.ids IS NOT NULL OPTION(RECOMPILE); ', @string_split_strings = N' @@ -3533,6 +3664,7 @@ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;', ) AS ids CROSS APPLY ids.nodes(''x'') AS x (x) ) AS ids + WHERE ids.ids IS NOT NULL OPTION(RECOMPILE); ', @troubleshoot_insert = N' @@ -3749,11 +3881,16 @@ BEGIN 7, @start_date ), + /* + Same seven days as @end_date above: these originals feed the + logging tables and debug output, so a different span here would + record a window the queries did not actually use + */ @end_date_original = DATEADD ( DAY, - 1, + 7, @start_date_original ); END; @@ -4191,7 +4328,7 @@ OPTION(RECOMPILE);' + @nc10; EXECUTE sys.sp_executesql @sql, N'@procedure_exists bit OUTPUT, - @procedure_name_quoted sysname', + @procedure_name_quoted nvarchar(1024)', /* nvarchar(1024) to match the variable's declaration; sysname truncated a quoted 3-part name over 128 chars, so OBJECT_ID failed and a valid procedure was falsely reported absent */ @procedure_exists OUTPUT, @procedure_name_quoted; @@ -4255,7 +4392,7 @@ OPTION(RECOMPILE);' + @nc10; EXECUTE sys.sp_executesql @sql, N'@procedure_exists bit OUTPUT, - @procedure_name_quoted sysname', + @procedure_name_quoted nvarchar(1024)', /* nvarchar(1024) to match the variable's declaration; sysname truncated a quoted 3-part name over 128 chars, so OBJECT_ID failed and a valid procedure was falsely reported absent */ @procedure_exists OUTPUT, @procedure_name_quoted; @@ -4379,16 +4516,19 @@ IF BEGIN RAISERROR('Query Store hints, feedback, and variants are not available prior to SQL Server 2022', 10, 1) WITH NOWAIT; - IF @get_all_databases = 0 + /* + Instance-level condition: these views are missing for EVERY database on a + pre-2022 instance, so halt in both single- and all-databases mode. The old + IF @get_all_databases = 0 guard let all-databases mode fall through and + return fully unfiltered results after only a level-10 warning. + */ + IF @debug = 1 BEGIN - IF @debug = 1 - BEGIN - GOTO DEBUG; - END; - ELSE - BEGIN - RETURN; - END; + GOTO DEBUG; + END; + ELSE + BEGIN + RETURN; END; END; @@ -4403,16 +4543,19 @@ AND @new = 0 BEGIN RAISERROR('Query Store wait stats are not available prior to SQL Server 2017', 10, 1) WITH NOWAIT; - IF @get_all_databases = 0 + /* + Instance-level condition (@new is a per-instance version flag): wait stats + are missing for EVERY database on a pre-2017 instance, so halt in both + single- and all-databases mode rather than falling through in all-databases + mode and ignoring the @wait_filter the caller asked for. + */ + IF @debug = 1 BEGIN - IF @debug = 1 - BEGIN - GOTO DEBUG; - END; - ELSE - BEGIN - RETURN; - END; + GOTO DEBUG; + END; + ELSE + BEGIN + RETURN; END; END; @@ -4444,16 +4587,19 @@ AND @wait_filter NOT IN BEGIN RAISERROR('The wait category (%s) you chose is invalid', 10, 1, @wait_filter) WITH NOWAIT; - IF @get_all_databases = 0 + /* + An invalid wait category is invalid for every database, so halt in both + single- and all-databases mode. Falling through in all-databases mode let + the invalid value reach the ELSE-less routing CASE, which returned NULL, + NULL'd the whole dynamic SQL, and silently produced zero rows. + */ + IF @debug = 1 BEGIN - IF @debug = 1 - BEGIN - GOTO DEBUG; - END; - ELSE - BEGIN - RETURN; - END; + GOTO DEBUG; + END; + ELSE + BEGIN + RETURN; END; END; @@ -4752,7 +4898,7 @@ BEGIN total_tempdb_mb decimal(38, 6) NOT NULL DEFAULT (0), avg_tempdb_mb decimal(38, 6) NULL, total_rows bigint NOT NULL DEFAULT (0), - avg_rows bigint NULL + avg_rows decimal(38, 6) NULL /* decimal, like every sibling average; bigint here integer-divided total_rows/total_executions and truncated (3/4 -> 0) */ ); CREATE TABLE @@ -4765,7 +4911,8 @@ BEGIN CREATE TABLE #hi_representative_text ( - query_hash binary(8) NOT NULL, + query_hash binary(8) NOT NULL + PRIMARY KEY CLUSTERED, query_sql_text nvarchar(max) NULL, rn bigint NOT NULL ); @@ -4800,7 +4947,9 @@ BEGIN query_hash binary(8) NOT NULL, wait_category_desc nvarchar(60) NOT NULL, total_wait_ms decimal(38, 6) NOT NULL, - rn bigint NOT NULL + rn bigint NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, rn) ); CREATE TABLE @@ -4817,21 +4966,27 @@ BEGIN #hi_id_staging_queries ( query_hash binary(8) NOT NULL, - query_id bigint NOT NULL + query_id bigint NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_id) ); CREATE TABLE #hi_id_staging_plans ( query_hash binary(8) NOT NULL, - plan_id bigint NOT NULL + plan_id bigint NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, plan_id) ); CREATE TABLE #hi_id_staging_objects ( query_hash binary(8) NOT NULL, - object_id integer NOT NULL + object_id integer NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, object_id) ); CREATE TABLE @@ -5145,7 +5300,7 @@ SELECT total_rows = SUM(ps.total_rows), avg_rows = - SUM(ps.total_rows) / + CONVERT(decimal(38, 6), SUM(ps.total_rows)) / NULLIF(SUM(ps.total_executions), 0) FROM #hi_plan_stats AS ps JOIN ' + @database_name_quoted + N'.sys.query_store_plan AS qsp @@ -5526,17 +5681,26 @@ OPTION(RECOMPILE);' + @nc10; SET STATISTICS XML ON; END; + /* + Weekend detection uses DATEDIFF(DAY, 0, local) % 7 IN (5, 6), which is + independent of @@DATEFIRST and language: SQL Server's day 0 (1900-01-01) + is a Monday, so the modulo is 0=Mon..5=Sat..6=Sun everywhere. The old + DATEPART(WEEKDAY, ...) IN (1, 7) only meant Sat+Sun under @@DATEFIRST = 7; + on a DATEFIRST = 1 server it mislabeled Monday as Weekend and Saturday as + a weekday. The SELECT and GROUP BY expressions below must stay identical. + */ SELECT @sql += N' SELECT sq.query_hash, time_bucket = CASE - WHEN DATEPART + WHEN DATEDIFF ( - WEEKDAY, + DAY, + 0, DATEADD(MINUTE, @utc_minutes_original, CONVERT(datetime, qsrsi.start_time)) - ) IN (1, 7) + ) % 7 IN (5, 6) THEN N''Weekend'' WHEN CONVERT ( @@ -5565,11 +5729,12 @@ AND qsrsi.start_time < @end_date GROUP BY sq.query_hash, CASE - WHEN DATEPART + WHEN DATEDIFF ( - WEEKDAY, + DAY, + 0, DATEADD(MINUTE, @utc_minutes_original, CONVERT(datetime, qsrsi.start_time)) - ) IN (1, 7) + ) % 7 IN (5, 6) THEN N''Weekend'' WHEN CONVERT ( @@ -5660,9 +5825,10 @@ OPTION(RECOMPILE);' + @nc10; tb.query_hash, tb.time_bucket, pct = + /* decimal, not integer: CONVERT(integer, 50.99) = 50, so a genuine 50.1-50.99% majority was truncated to exactly 50 and failed the '> 50' test below, mislabeling the query 'Spread' */ CONVERT ( - integer, + decimal(5, 1), 100.0 * tb.executions / SUM(tb.executions) OVER (PARTITION BY tb.query_hash) ) @@ -5923,6 +6089,8 @@ OPTION(RECOMPILE);' + @nc10; so.object_id FROM #hi_id_staging_objects AS so WHERE so.query_hash = i.query_hash + ORDER BY + so.object_id ), @database_id ) @@ -5937,6 +6105,8 @@ OPTION(RECOMPILE);' + @nc10; so.object_id FROM #hi_id_staging_objects AS so WHERE so.query_hash = i.query_hash + ORDER BY + so.object_id ), @database_id ) @@ -6783,28 +6953,1756 @@ OUTER APPLY ) AS qp0 WHERE qp0.n = 1 ) AS qp -' + - CASE - WHEN @primary_window IS NULL - THEN N'' - WHEN LOWER(@primary_window) LIKE N'b%' - THEN N'WHERE o.primary_window LIKE N''Business%''' + @nc10 - WHEN LOWER(@primary_window) LIKE N'o%' - THEN N'WHERE o.primary_window LIKE N''Off-hours%''' + @nc10 - WHEN LOWER(@primary_window) LIKE N'w%' - THEN N'WHERE o.primary_window LIKE N''Weekend%''' + @nc10 - ELSE N'' - END + N'ORDER BY - o.impact_score DESC, - ' + - CASE LOWER(@sort_order) - WHEN 'duration' THEN N'o.duration_share' - WHEN 'physical reads' THEN N'o.physical_reads_share' - WHEN 'writes' THEN N'o.writes_share' - WHEN 'memory' THEN N'o.memory_share' - WHEN 'executions' THEN N'o.executions_share' - ELSE N'o.cpu_share' - END + N' DESC +' + + CASE + WHEN @primary_window IS NULL + THEN N'' + WHEN LOWER(@primary_window) LIKE N'b%' + THEN N'WHERE o.primary_window LIKE N''Business%''' + @nc10 + WHEN LOWER(@primary_window) LIKE N'o%' + THEN N'WHERE o.primary_window LIKE N''Off-hours%''' + @nc10 + WHEN LOWER(@primary_window) LIKE N'w%' + THEN N'WHERE o.primary_window LIKE N''Weekend%''' + @nc10 + ELSE N'' + END + N'ORDER BY + o.impact_score DESC, + ' + + CASE LOWER(@sort_order) + WHEN 'duration' THEN N'o.duration_share' + WHEN 'physical reads' THEN N'o.physical_reads_share' + WHEN 'writes' THEN N'o.writes_share' + WHEN 'memory' THEN N'o.memory_share' + WHEN 'executions' THEN N'o.executions_share' + ELSE N'o.cpu_share' + END + N' DESC +OPTION(RECOMPILE);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + EXECUTE sys.sp_executesql + @sql, + N'@database_name sysname, + @start_date datetimeoffset(7), + @end_date datetimeoffset(7), + @utc_offset_string varchar(6), + @timezone sysname', + @database_name, + @start_date, + @end_date, + @utc_offset_string, + @timezone; + + IF @troubleshoot_performance = 1 + BEGIN + SET STATISTICS XML OFF; + + EXECUTE sys.sp_executesql + @troubleshoot_update, + N'@current_table nvarchar(100)', + @current_table; + + EXECUTE sys.sp_executesql + @troubleshoot_info, + N'@sql nvarchar(max), + @current_table nvarchar(100)', + @sql, + @current_table; + END; + + /*Exit: go to DEBUG dump or return*/ + IF @debug = 1 + BEGIN + GOTO DEBUG; + END; + ELSE + BEGIN + RETURN; + END; +END; /*End @find_high_impact*/ + +/* +Find parameter sensitive queries: plan shapes (query_hash + query_plan_hash) +whose runtime metrics swing wildly across executions of the same plan. +Groups every plan_id that compiled to the same shape, computes a proper +execution-weighted coefficient of variation from the stdev columns, and +ranks shapes by how unpredictable the @sort_order metric is. +*/ +IF @find_parameter_sensitive = 1 +BEGIN + /*Create temp tables for parameter sensitivity analysis*/ + CREATE TABLE + #ps_intervals + ( + runtime_stats_interval_id bigint NOT NULL + PRIMARY KEY CLUSTERED + ); + + CREATE TABLE + #ps_shape_stats + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + query_count bigint NOT NULL, + plan_count bigint NOT NULL, + last_execution_time datetimeoffset(7) NULL, + total_executions bigint NOT NULL, + aborted_executions bigint NOT NULL, + exception_executions bigint NOT NULL, + total_cpu_ms decimal(38, 6) NULL, + min_cpu_ms decimal(38, 6) NULL, + avg_cpu_ms decimal(38, 6) NULL, + max_cpu_ms decimal(38, 6) NULL, + cpu_volatility decimal(19, 4) NULL, + total_duration_ms decimal(38, 6) NULL, + min_duration_ms decimal(38, 6) NULL, + avg_duration_ms decimal(38, 6) NULL, + max_duration_ms decimal(38, 6) NULL, + duration_volatility decimal(19, 4) NULL, + min_rows bigint NULL, + avg_rows decimal(38, 6) NULL, + max_rows bigint NULL, + rows_volatility decimal(19, 4) NULL, + min_memory_mb decimal(38, 6) NULL, + avg_memory_mb decimal(38, 6) NULL, + max_memory_mb decimal(38, 6) NULL, + memory_volatility decimal(19, 4) NULL, + min_physical_reads_mb decimal(38, 6) NULL, + avg_physical_reads_mb decimal(38, 6) NULL, + max_physical_reads_mb decimal(38, 6) NULL, + physical_reads_volatility decimal(19, 4) NULL, + min_writes_mb decimal(38, 6) NULL, + avg_writes_mb decimal(38, 6) NULL, + max_writes_mb decimal(38, 6) NULL, + writes_volatility decimal(19, 4) NULL, + min_tempdb_mb decimal(38, 6) NULL, + avg_tempdb_mb decimal(38, 6) NULL, + max_tempdb_mb decimal(38, 6) NULL, + tempdb_volatility decimal(19, 4) NULL, + max_dop bigint NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash) + ); + + CREATE TABLE + #ps_query_rollup + ( + query_hash binary(8) NOT NULL + PRIMARY KEY CLUSTERED, + shape_count bigint NOT NULL, + min_shape_avg_cpu_ms decimal(38, 6) NULL, + max_shape_avg_cpu_ms decimal(38, 6) NULL + ); + + CREATE TABLE + #ps_interesting + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash) + ); + + CREATE TABLE + #ps_id_staging + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + query_id bigint NOT NULL, + plan_id bigint NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash, query_id, plan_id) + ); + + CREATE TABLE + #ps_representative_text + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + query_sql_text nvarchar(max) NULL, + rn bigint NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash) + ); + + CREATE TABLE + #ps_object_staging + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + object_id integer NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash, object_id) + ); + + CREATE TABLE + #ps_wait_staging + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + wait_category_desc nvarchar(60) NOT NULL, + total_wait_ms decimal(38, 6) NOT NULL, + rn bigint NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash, rn) + ); + + CREATE TABLE + #ps_shape_waits + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + top_waits nvarchar(max) NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash) + ); + + CREATE TABLE + #ps_variant_flags + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + has_variants bit NOT NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash) + ); + + CREATE TABLE + #ps_identifiers + ( + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + query_id_list nvarchar(max) NULL, + plan_id_list nvarchar(max) NULL, + object_name nvarchar(500) NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash) + ); + + CREATE TABLE + #ps_output + ( + object_name nvarchar(500) NULL, + query_sql_text nvarchar(max) NULL, + top_waits nvarchar(max) NULL, + query_hash binary(8) NOT NULL, + query_plan_hash binary(8) NOT NULL, + query_count bigint NOT NULL, + plan_count bigint NOT NULL, + shapes_for_query_hash bigint NOT NULL, + query_id_list nvarchar(max) NULL, + plan_id_list nvarchar(max) NULL, + ranked_on nvarchar(40) NOT NULL, + volatility_score decimal(19, 4) NULL, + signals nvarchar(4000) NULL, + total_executions bigint NOT NULL, + aborted_executions bigint NOT NULL, + exception_executions bigint NOT NULL, + min_cpu_ms decimal(38, 6) NULL, + avg_cpu_ms decimal(38, 6) NULL, + max_cpu_ms decimal(38, 6) NULL, + cpu_volatility decimal(19, 4) NULL, + min_duration_ms decimal(38, 6) NULL, + avg_duration_ms decimal(38, 6) NULL, + max_duration_ms decimal(38, 6) NULL, + duration_volatility decimal(19, 4) NULL, + min_rows bigint NULL, + avg_rows decimal(38, 6) NULL, + max_rows bigint NULL, + rows_volatility decimal(19, 4) NULL, + last_execution_time datetimeoffset(7) NULL, + variance_metrics xml NULL, + PRIMARY KEY CLUSTERED + (query_hash, query_plan_hash) + ); + + /* + Local knobs: minimum executions for variance to mean anything, + and which metric's volatility ranks the results + */ + DECLARE + @ps_min_executions bigint = + ISNULL(@execution_count, 2), + @ps_rank_metric nvarchar(20) = + CASE + WHEN @sort_order IN + ( + 'duration', + 'physical reads', + 'writes', + 'memory', + 'rows' + ) + THEN @sort_order + WHEN @sort_order = 'tempdb' + AND @new = 1 + THEN @sort_order + ELSE N'cpu' + END; + + /*Step 1a: Stage interval IDs for the time window*/ + SELECT + @current_table = 'inserting #ps_intervals', + @sql = @isolation_level; + + IF @troubleshoot_performance = 1 + BEGIN + EXECUTE sys.sp_executesql + @troubleshoot_insert, + N'@current_table nvarchar(100)', + @current_table; + + SET STATISTICS XML ON; + END; + + SELECT + @sql += N' +SELECT + qsrsi.runtime_stats_interval_id +FROM ' + @database_name_quoted + N'.sys.query_store_runtime_stats_interval AS qsrsi +WHERE qsrsi.start_time >= @start_date +AND qsrsi.start_time < @end_date +OPTION(RECOMPILE);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + INSERT + #ps_intervals WITH (TABLOCK) + ( + runtime_stats_interval_id + ) + EXECUTE sys.sp_executesql + @sql, + N'@start_date datetimeoffset(7), + @end_date datetimeoffset(7)', + @start_date, + @end_date; + + IF @troubleshoot_performance = 1 + BEGIN + SET STATISTICS XML OFF; + + EXECUTE sys.sp_executesql + @troubleshoot_update, + N'@current_table nvarchar(100)', + @current_table; + + EXECUTE sys.sp_executesql + @troubleshoot_info, + N'@sql nvarchar(max), + @current_table nvarchar(100)', + @sql, + @current_table; + END; + + /* + Step 1b: Aggregate runtime stats to plan shape level. + Only regular executions (execution_type = 0) feed the statistics, + because aborted executions have truncated metrics that poison + min/max and stdev; they get counted separately instead. + The variance combination is the standard execution-weighted + formula: combined variance = SUM(n * (stdev^2 + avg^2)) / SUM(n) + minus the square of the combined average, computed in float + because the native microsecond values squared overflow decimals. + */ + SELECT + @current_table = 'inserting #ps_shape_stats', + @sql = @isolation_level; + + IF @troubleshoot_performance = 1 + BEGIN + EXECUTE sys.sp_executesql + @troubleshoot_insert, + N'@current_table nvarchar(100)', + @current_table; + + SET STATISTICS XML ON; + END; + + SELECT + @sql += N' +SELECT + x.query_hash, + x.query_plan_hash, + x.query_count, + x.plan_count, + x.last_execution_time, + total_executions = x.n, + x.aborted_executions, + x.exception_executions, + total_cpu_ms = + CONVERT(decimal(38, 6), x.cpu_sum / 1000.0), + min_cpu_ms = + CONVERT(decimal(38, 6), x.cpu_min / 1000.0), + avg_cpu_ms = + CONVERT(decimal(38, 6), x.cpu_sum / v.n_float / 1000.0), + max_cpu_ms = + CONVERT(decimal(38, 6), x.cpu_max / 1000.0), + cpu_volatility = + CONVERT + ( + decimal(19, 4), + SQRT + ( + CASE + WHEN (x.cpu_ex2 / v.n_float) - POWER(x.cpu_sum / v.n_float, 2) < 0 + THEN 0 + ELSE (x.cpu_ex2 / v.n_float) - POWER(x.cpu_sum / v.n_float, 2) + END + ) / + NULLIF(x.cpu_sum / v.n_float, 0) + ), + total_duration_ms = + CONVERT(decimal(38, 6), x.duration_sum / 1000.0), + min_duration_ms = + CONVERT(decimal(38, 6), x.duration_min / 1000.0), + avg_duration_ms = + CONVERT(decimal(38, 6), x.duration_sum / v.n_float / 1000.0), + max_duration_ms = + CONVERT(decimal(38, 6), x.duration_max / 1000.0), + duration_volatility = + CONVERT + ( + decimal(19, 4), + SQRT + ( + CASE + WHEN (x.duration_ex2 / v.n_float) - POWER(x.duration_sum / v.n_float, 2) < 0 + THEN 0 + ELSE (x.duration_ex2 / v.n_float) - POWER(x.duration_sum / v.n_float, 2) + END + ) / + NULLIF(x.duration_sum / v.n_float, 0) + ), + min_rows = + CONVERT(bigint, x.rows_min), + avg_rows = + CONVERT(decimal(38, 6), x.rows_sum / v.n_float), + max_rows = + CONVERT(bigint, x.rows_max), + rows_volatility = + CONVERT + ( + decimal(19, 4), + SQRT + ( + CASE + WHEN (x.rows_ex2 / v.n_float) - POWER(x.rows_sum / v.n_float, 2) < 0 + THEN 0 + ELSE (x.rows_ex2 / v.n_float) - POWER(x.rows_sum / v.n_float, 2) + END + ) / + NULLIF(x.rows_sum / v.n_float, 0) + ), + min_memory_mb = + CONVERT(decimal(38, 6), x.memory_min * 8.0 / 1024.0), + avg_memory_mb = + CONVERT(decimal(38, 6), x.memory_sum / v.n_float * 8.0 / 1024.0), + max_memory_mb = + CONVERT(decimal(38, 6), x.memory_max * 8.0 / 1024.0), + memory_volatility = + CONVERT + ( + decimal(19, 4), + SQRT + ( + CASE + WHEN (x.memory_ex2 / v.n_float) - POWER(x.memory_sum / v.n_float, 2) < 0 + THEN 0 + ELSE (x.memory_ex2 / v.n_float) - POWER(x.memory_sum / v.n_float, 2) + END + ) / + NULLIF(x.memory_sum / v.n_float, 0) + ), + min_physical_reads_mb = + CONVERT(decimal(38, 6), x.physical_reads_min * 8.0 / 1024.0), + avg_physical_reads_mb = + CONVERT(decimal(38, 6), x.physical_reads_sum / v.n_float * 8.0 / 1024.0), + max_physical_reads_mb = + CONVERT(decimal(38, 6), x.physical_reads_max * 8.0 / 1024.0), + physical_reads_volatility = + CONVERT + ( + decimal(19, 4), + SQRT + ( + CASE + WHEN (x.physical_reads_ex2 / v.n_float) - POWER(x.physical_reads_sum / v.n_float, 2) < 0 + THEN 0 + ELSE (x.physical_reads_ex2 / v.n_float) - POWER(x.physical_reads_sum / v.n_float, 2) + END + ) / + NULLIF(x.physical_reads_sum / v.n_float, 0) + ), + min_writes_mb = + CONVERT(decimal(38, 6), x.writes_min * 8.0 / 1024.0), + avg_writes_mb = + CONVERT(decimal(38, 6), x.writes_sum / v.n_float * 8.0 / 1024.0), + max_writes_mb = + CONVERT(decimal(38, 6), x.writes_max * 8.0 / 1024.0), + writes_volatility = + CONVERT + ( + decimal(19, 4), + SQRT + ( + CASE + WHEN (x.writes_ex2 / v.n_float) - POWER(x.writes_sum / v.n_float, 2) < 0 + THEN 0 + ELSE (x.writes_ex2 / v.n_float) - POWER(x.writes_sum / v.n_float, 2) + END + ) / + NULLIF(x.writes_sum / v.n_float, 0) + ), + min_tempdb_mb = + CONVERT(decimal(38, 6), x.tempdb_min * 8.0 / 1024.0), + avg_tempdb_mb = + CONVERT(decimal(38, 6), x.tempdb_sum / v.n_float * 8.0 / 1024.0), + max_tempdb_mb = + CONVERT(decimal(38, 6), x.tempdb_max * 8.0 / 1024.0), + tempdb_volatility = + CONVERT + ( + decimal(19, 4), + SQRT + ( + CASE + WHEN (x.tempdb_ex2 / v.n_float) - POWER(x.tempdb_sum / v.n_float, 2) < 0 + THEN 0 + ELSE (x.tempdb_ex2 / v.n_float) - POWER(x.tempdb_sum / v.n_float, 2) + END + ) / + NULLIF(x.tempdb_sum / v.n_float, 0) + ), + x.max_dop +FROM +( + SELECT + qsq.query_hash, + qsp.query_plan_hash, + query_count = + COUNT_BIG(DISTINCT CASE WHEN qsrs.execution_type = 0 THEN qsp.query_id END), + plan_count = + COUNT_BIG(DISTINCT CASE WHEN qsrs.execution_type = 0 THEN qsp.plan_id END), + last_execution_time = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.last_execution_time END), + n = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions ELSE 0 END), + aborted_executions = + SUM(CASE WHEN qsrs.execution_type = 3 THEN qsrs.count_executions ELSE 0 END), + exception_executions = + SUM(CASE WHEN qsrs.execution_type = 4 THEN qsrs.count_executions ELSE 0 END), + cpu_sum = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_cpu_time * qsrs.count_executions END), + cpu_ex2 = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_cpu_time * qsrs.stdev_cpu_time + qsrs.avg_cpu_time * qsrs.avg_cpu_time) END), + cpu_min = + MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_cpu_time END), + cpu_max = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_cpu_time END), + duration_sum = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_duration * qsrs.count_executions END), + duration_ex2 = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_duration * qsrs.stdev_duration + qsrs.avg_duration * qsrs.avg_duration) END), + duration_min = + MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_duration END), + duration_max = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_duration END), + rows_sum = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_rowcount * qsrs.count_executions END), + rows_ex2 = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_rowcount * qsrs.stdev_rowcount + qsrs.avg_rowcount * qsrs.avg_rowcount) END), + rows_min = + MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_rowcount END), + rows_max = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_rowcount END), + memory_sum = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_query_max_used_memory * qsrs.count_executions END), + memory_ex2 = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_query_max_used_memory * qsrs.stdev_query_max_used_memory + qsrs.avg_query_max_used_memory * qsrs.avg_query_max_used_memory) END), + memory_min = + MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_query_max_used_memory END), + memory_max = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_query_max_used_memory END), + physical_reads_sum = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_physical_io_reads * qsrs.count_executions END), + physical_reads_ex2 = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_physical_io_reads * qsrs.stdev_physical_io_reads + qsrs.avg_physical_io_reads * qsrs.avg_physical_io_reads) END), + physical_reads_min = + MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_physical_io_reads END), + physical_reads_max = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_physical_io_reads END), + writes_sum = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_logical_io_writes * qsrs.count_executions END), + writes_ex2 = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_logical_io_writes * qsrs.stdev_logical_io_writes + qsrs.avg_logical_io_writes * qsrs.avg_logical_io_writes) END), + writes_min = + MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_logical_io_writes END), + writes_max = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_logical_io_writes END),' + + CASE + WHEN @new = 1 + THEN N' + tempdb_sum = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.avg_tempdb_space_used * qsrs.count_executions END), + tempdb_ex2 = + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions * (qsrs.stdev_tempdb_space_used * qsrs.stdev_tempdb_space_used + qsrs.avg_tempdb_space_used * qsrs.avg_tempdb_space_used) END), + tempdb_min = + MIN(CASE WHEN qsrs.execution_type = 0 THEN qsrs.min_tempdb_space_used END), + tempdb_max = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_tempdb_space_used END),' + ELSE N' + /*NULL, not 0: a zero would render in variance_metrics as a real + measurement of no tempdb use, when the truth is the columns + do not exist before 2017. NULL attributes drop out of the XML.*/ + tempdb_sum = + CONVERT(float, NULL), + tempdb_ex2 = + CONVERT(float, NULL), + tempdb_min = + CONVERT(float, NULL), + tempdb_max = + CONVERT(float, NULL),' + END + N' + max_dop = + MAX(CASE WHEN qsrs.execution_type = 0 THEN qsrs.max_dop END) + FROM ' + @database_name_quoted + N'.sys.query_store_runtime_stats AS qsrs + JOIN ' + @database_name_quoted + N'.sys.query_store_plan AS qsp + ON qsp.plan_id = qsrs.plan_id + JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq + ON qsq.query_id = qsp.query_id + WHERE EXISTS + ( + SELECT + 1/0 + FROM #ps_intervals AS pi + WHERE pi.runtime_stats_interval_id = qsrs.runtime_stats_interval_id + ) + GROUP BY + qsq.query_hash, + qsp.query_plan_hash + HAVING + SUM(CASE WHEN qsrs.execution_type = 0 THEN qsrs.count_executions ELSE 0 END) > 0 +) AS x +CROSS APPLY +( + VALUES + (CONVERT(float, x.n)) +) AS v (n_float) +OPTION(RECOMPILE, HASH JOIN);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + INSERT + #ps_shape_stats WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + query_count, + plan_count, + last_execution_time, + total_executions, + aborted_executions, + exception_executions, + total_cpu_ms, + min_cpu_ms, + avg_cpu_ms, + max_cpu_ms, + cpu_volatility, + total_duration_ms, + min_duration_ms, + avg_duration_ms, + max_duration_ms, + duration_volatility, + min_rows, + avg_rows, + max_rows, + rows_volatility, + min_memory_mb, + avg_memory_mb, + max_memory_mb, + memory_volatility, + min_physical_reads_mb, + avg_physical_reads_mb, + max_physical_reads_mb, + physical_reads_volatility, + min_writes_mb, + avg_writes_mb, + max_writes_mb, + writes_volatility, + min_tempdb_mb, + avg_tempdb_mb, + max_tempdb_mb, + tempdb_volatility, + max_dop + ) + EXECUTE sys.sp_executesql + @sql; + + IF @troubleshoot_performance = 1 + BEGIN + SET STATISTICS XML OFF; + + EXECUTE sys.sp_executesql + @troubleshoot_update, + N'@current_table nvarchar(100)', + @current_table; + + EXECUTE sys.sp_executesql + @troubleshoot_info, + N'@sql nvarchar(max), + @current_table nvarchar(100)', + @sql, + @current_table; + END; + + /*Step 2: Roll up shape counts and average divergence per query_hash (static SQL)*/ + INSERT + #ps_query_rollup WITH (TABLOCK) + ( + query_hash, + shape_count, + min_shape_avg_cpu_ms, + max_shape_avg_cpu_ms + ) + SELECT + ps.query_hash, + shape_count = + COUNT_BIG(*), + min_shape_avg_cpu_ms = + MIN(ps.avg_cpu_ms), + max_shape_avg_cpu_ms = + MAX(ps.avg_cpu_ms) + FROM #ps_shape_stats AS ps + GROUP BY + ps.query_hash; + + /* + Step 3: Pick the top N shapes by volatility of the ranking metric. + The noise floors keep trivia out: a shape must have enough executions + for variance to mean anything, and must have done a measurable amount + of work at least once. @duration_ms takes over the work floor when set. + */ + INSERT + #ps_interesting WITH (TABLOCK) + ( + query_hash, + query_plan_hash + ) + SELECT TOP (@top) + ps.query_hash, + ps.query_plan_hash + FROM #ps_shape_stats AS ps + WHERE ps.total_executions >= @ps_min_executions + AND + ( + ( + @duration_ms IS NOT NULL + AND ps.max_duration_ms >= @duration_ms + ) + OR + ( + @duration_ms IS NULL + AND + ( + ps.max_cpu_ms >= 10 + OR ps.max_duration_ms >= 100 + ) + ) + ) + ORDER BY + CASE @ps_rank_metric + WHEN N'duration' THEN ps.duration_volatility + WHEN N'physical reads' THEN ps.physical_reads_volatility + WHEN N'writes' THEN ps.writes_volatility + WHEN N'memory' THEN ps.memory_volatility + WHEN N'rows' THEN ps.rows_volatility + WHEN N'tempdb' THEN ps.tempdb_volatility + ELSE ps.cpu_volatility + END DESC, + ps.total_cpu_ms DESC, + ps.query_hash, + ps.query_plan_hash; + + /*Step 3b: Shape volatility summary, returned before the details*/ + SELECT + total_shapes = + ( + SELECT + COUNT_BIG(*) + FROM #ps_shape_stats AS ps + ), + shapes_past_floors = + ( + SELECT + COUNT_BIG(*) + FROM #ps_shape_stats AS ps + WHERE ps.total_executions >= @ps_min_executions + AND + ( + ( + @duration_ms IS NOT NULL + AND ps.max_duration_ms >= @duration_ms + ) + OR + ( + @duration_ms IS NULL + AND + ( + ps.max_cpu_ms >= 10 + OR ps.max_duration_ms >= 100 + ) + ) + ) + ), + surfaced_shapes = + ( + SELECT + COUNT_BIG(*) + FROM #ps_interesting AS i + ), + multi_shape_query_hashes = + ( + SELECT + COUNT_BIG(*) + FROM #ps_query_rollup AS qr + WHERE qr.shape_count > 1 + ), + ranked_on = + @ps_rank_metric + N' volatility'; + + /* + Step 4: Stage query and plan ids for the interesting shapes. + Window-scoped to regular executions so the id lists agree with + query_count and plan_count in the shape statistics. + */ + SELECT + @current_table = 'inserting #ps_id_staging', + @sql = @isolation_level; + + IF @troubleshoot_performance = 1 + BEGIN + EXECUTE sys.sp_executesql + @troubleshoot_insert, + N'@current_table nvarchar(100)', + @current_table; + + SET STATISTICS XML ON; + END; + + SELECT + @sql += N' +SELECT DISTINCT + qsq.query_hash, + qsp.query_plan_hash, + qsp.query_id, + qsp.plan_id +FROM ' + @database_name_quoted + N'.sys.query_store_plan AS qsp +JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq + ON qsq.query_id = qsp.query_id +JOIN #ps_interesting AS i + ON i.query_hash = qsq.query_hash + AND i.query_plan_hash = qsp.query_plan_hash +WHERE EXISTS +( + SELECT + 1/0 + FROM ' + @database_name_quoted + N'.sys.query_store_runtime_stats AS qsrs + JOIN #ps_intervals AS pi + ON pi.runtime_stats_interval_id = qsrs.runtime_stats_interval_id + WHERE qsrs.plan_id = qsp.plan_id + AND qsrs.execution_type = 0 +) +OPTION(RECOMPILE);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + INSERT + #ps_id_staging WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + query_id, + plan_id + ) + EXECUTE sys.sp_executesql + @sql; + + IF @troubleshoot_performance = 1 + BEGIN + SET STATISTICS XML OFF; + + EXECUTE sys.sp_executesql + @troubleshoot_update, + N'@current_table nvarchar(100)', + @current_table; + + EXECUTE sys.sp_executesql + @troubleshoot_info, + N'@sql nvarchar(max), + @current_table nvarchar(100)', + @sql, + @current_table; + END; + + /*Step 4b: Representative query text per shape (the most-executed query_id in the window)*/ + SELECT + @current_table = 'inserting #ps_representative_text', + @sql = @isolation_level; + + IF @troubleshoot_performance = 1 + BEGIN + EXECUTE sys.sp_executesql + @troubleshoot_insert, + N'@current_table nvarchar(100)', + @current_table; + + SET STATISTICS XML ON; + END; + + SELECT + @sql += N' +SELECT + ranked.query_hash, + ranked.query_plan_hash, + qsqt.query_sql_text, + ranked.rn +FROM +( + SELECT + s.query_hash, + s.query_plan_hash, + qsq.query_text_id, + rn = + ROW_NUMBER() OVER + ( + PARTITION BY + s.query_hash, + s.query_plan_hash + ORDER BY + SUM(qsrs.count_executions) DESC + ) + FROM #ps_id_staging AS s + JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq + ON qsq.query_id = s.query_id + JOIN ' + @database_name_quoted + N'.sys.query_store_runtime_stats AS qsrs + ON qsrs.plan_id = s.plan_id + WHERE EXISTS + ( + SELECT + 1/0 + FROM #ps_intervals AS pi + WHERE pi.runtime_stats_interval_id = qsrs.runtime_stats_interval_id + ) + AND qsrs.execution_type = 0 + GROUP BY + s.query_hash, + s.query_plan_hash, + qsq.query_text_id +) AS ranked +JOIN ' + @database_name_quoted + N'.sys.query_store_query_text AS qsqt + ON qsqt.query_text_id = ranked.query_text_id +WHERE ranked.rn = 1 +OPTION(RECOMPILE);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + INSERT + #ps_representative_text WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + query_sql_text, + rn + ) + EXECUTE sys.sp_executesql + @sql; + + IF @troubleshoot_performance = 1 + BEGIN + SET STATISTICS XML OFF; + + EXECUTE sys.sp_executesql + @troubleshoot_update, + N'@current_table nvarchar(100)', + @current_table; + + EXECUTE sys.sp_executesql + @troubleshoot_info, + N'@sql nvarchar(max), + @current_table nvarchar(100)', + @sql, + @current_table; + END; + + /*Step 4c: Object ids per shape, for module name resolution*/ + SELECT + @sql = @isolation_level; + + SELECT + @sql += N' +SELECT DISTINCT + s.query_hash, + s.query_plan_hash, + qsq.object_id +FROM #ps_id_staging AS s +JOIN ' + @database_name_quoted + N'.sys.query_store_query AS qsq + ON qsq.query_id = s.query_id +WHERE qsq.object_id > 0 +OPTION(RECOMPILE);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + INSERT + #ps_object_staging WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + object_id + ) + EXECUTE sys.sp_executesql + @sql; + + /*Step 5: Wait stats per shape (SQL 2017+ only, two-stage approach)*/ + IF + ( + @new = 1 + AND @query_store_waits_enabled = 1 + ) + BEGIN + /*Stage 1: Dynamic SQL populates staging table*/ + SELECT + @current_table = 'inserting #ps_wait_staging', + @sql = @isolation_level; + + IF @troubleshoot_performance = 1 + BEGIN + EXECUTE sys.sp_executesql + @troubleshoot_insert, + N'@current_table nvarchar(100)', + @current_table; + + SET STATISTICS XML ON; + END; + + SELECT + @sql += N' +SELECT + s.query_hash, + s.query_plan_hash, + qsws.wait_category_desc, + total_wait_ms = + SUM(qsws.total_query_wait_time_ms), + rn = + ROW_NUMBER() OVER + ( + PARTITION BY + s.query_hash, + s.query_plan_hash + ORDER BY + SUM(qsws.total_query_wait_time_ms) DESC + ) +FROM ' + @database_name_quoted + N'.sys.query_store_wait_stats AS qsws +JOIN #ps_id_staging AS s + ON qsws.plan_id = s.plan_id +WHERE EXISTS +( + SELECT + 1/0 + FROM #ps_intervals AS pi + WHERE pi.runtime_stats_interval_id = qsws.runtime_stats_interval_id +) +GROUP BY + s.query_hash, + s.query_plan_hash, + qsws.wait_category_desc +OPTION(RECOMPILE);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + INSERT + #ps_wait_staging WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + wait_category_desc, + total_wait_ms, + rn + ) + EXECUTE sys.sp_executesql + @sql; + + IF @troubleshoot_performance = 1 + BEGIN + SET STATISTICS XML OFF; + + EXECUTE sys.sp_executesql + @troubleshoot_update, + N'@current_table nvarchar(100)', + @current_table; + + EXECUTE sys.sp_executesql + @troubleshoot_info, + N'@sql nvarchar(max), + @current_table nvarchar(100)', + @sql, + @current_table; + END; + + /*Stage 2: Aggregate top 3 waits per shape (static SQL, XML PATH)*/ + INSERT + #ps_shape_waits WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + top_waits + ) + SELECT + ws.query_hash, + ws.query_plan_hash, + top_waits = + STUFF + ( + ( + SELECT + N', ' + + ws2.wait_category_desc + + N' (' + + CONVERT(nvarchar(20), CONVERT(bigint, ws2.total_wait_ms)) + + N' ms)' + FROM #ps_wait_staging AS ws2 + WHERE ws2.query_hash = ws.query_hash + AND ws2.query_plan_hash = ws.query_plan_hash + AND ws2.rn <= 3 + ORDER BY + ws2.total_wait_ms DESC + FOR + XML PATH(N''), + TYPE + ).value(N'./text()[1]', N'nvarchar(max)'), + 1, + 2, + N'' + ) + FROM + ( + SELECT DISTINCT + ws.query_hash, + ws.query_plan_hash + FROM #ps_wait_staging AS ws + ) AS ws; + END; /*End wait stats*/ + + /*Step 5b: Flag shapes the Parameter Sensitive Plan feature already touched (SQL 2022+ only)*/ + IF @sql_2022_views = 1 + BEGIN + SELECT + @current_table = 'inserting #ps_variant_flags', + @sql = @isolation_level; + + SELECT + @sql += N' +SELECT DISTINCT + s.query_hash, + s.query_plan_hash, + has_variants = + CONVERT(bit, 1) +FROM #ps_id_staging AS s +WHERE EXISTS +( + SELECT + 1/0 + FROM ' + @database_name_quoted + N'.sys.query_store_query_variant AS qsqv + WHERE qsqv.query_variant_query_id = s.query_id + OR qsqv.parent_query_id = s.query_id +) +OPTION(RECOMPILE);' + @nc10; + + IF @debug = 1 + BEGIN + PRINT LEN(@sql); + PRINT @sql; + END; + + INSERT + #ps_variant_flags WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + has_variants + ) + EXECUTE sys.sp_executesql + @sql; + END; /*End variant flags*/ + + /*Step 5c: Aggregate ids with XML PATH and resolve object names (static SQL)*/ + INSERT + #ps_identifiers WITH (TABLOCK) + ( + query_hash, + query_plan_hash, + query_id_list, + plan_id_list, + object_name + ) + SELECT + i.query_hash, + i.query_plan_hash, + query_id_list = + STUFF + ( + ( + SELECT DISTINCT + N', ' + + CONVERT(nvarchar(20), s.query_id) + FROM #ps_id_staging AS s + WHERE s.query_hash = i.query_hash + AND s.query_plan_hash = i.query_plan_hash + ORDER BY + N', ' + + CONVERT(nvarchar(20), s.query_id) + FOR + XML PATH(N''), + TYPE + ).value(N'./text()[1]', N'nvarchar(max)'), + 1, + 2, + N'' + ), + plan_id_list = + STUFF + ( + ( + SELECT DISTINCT + N', ' + + CONVERT(nvarchar(20), s.plan_id) + FROM #ps_id_staging AS s + WHERE s.query_hash = i.query_hash + AND s.query_plan_hash = i.query_plan_hash + ORDER BY + N', ' + + CONVERT(nvarchar(20), s.plan_id) + FOR + XML PATH(N''), + TYPE + ).value(N'./text()[1]', N'nvarchar(max)'), + 1, + 2, + N'' + ), + object_name = + ISNULL + ( + QUOTENAME + ( + OBJECT_SCHEMA_NAME + ( + ( + SELECT TOP (1) + os.object_id + FROM #ps_object_staging AS os + WHERE os.query_hash = i.query_hash + AND os.query_plan_hash = i.query_plan_hash + ORDER BY + os.object_id + ), + @database_id + ) + ) + + N'.' + + QUOTENAME + ( + OBJECT_NAME + ( + ( + SELECT TOP (1) + os.object_id + FROM #ps_object_staging AS os + WHERE os.query_hash = i.query_hash + AND os.query_plan_hash = i.query_plan_hash + ORDER BY + os.object_id + ), + @database_id + ) + ), + CASE + WHEN EXISTS + ( + SELECT + 1/0 + FROM #ps_object_staging AS os + WHERE os.query_hash = i.query_hash + AND os.query_plan_hash = i.query_plan_hash + ) + THEN N'Unknown object_id' + ELSE N'Adhoc' + END + ) + FROM #ps_interesting AS i; + + /*Step 6: Assemble output (static SQL, no plans yet)*/ + SELECT + @current_table = 'inserting #ps_output', + @sql = N''; + + IF @troubleshoot_performance = 1 + BEGIN + EXECUTE sys.sp_executesql + @troubleshoot_insert, + N'@current_table nvarchar(100)', + @current_table; + + SET STATISTICS XML ON; + END; + + INSERT + #ps_output WITH (TABLOCK) + ( + object_name, + query_sql_text, + top_waits, + query_hash, + query_plan_hash, + query_count, + plan_count, + shapes_for_query_hash, + query_id_list, + plan_id_list, + ranked_on, + volatility_score, + signals, + total_executions, + aborted_executions, + exception_executions, + min_cpu_ms, + avg_cpu_ms, + max_cpu_ms, + cpu_volatility, + min_duration_ms, + avg_duration_ms, + max_duration_ms, + duration_volatility, + min_rows, + avg_rows, + max_rows, + rows_volatility, + last_execution_time, + variance_metrics + ) + SELECT + qi.object_name, + rt.query_sql_text, + top_waits = + CASE + WHEN @new = 1 + AND @query_store_waits_enabled = 1 + THEN qw.top_waits + END, + ps.query_hash, + ps.query_plan_hash, + ps.query_count, + ps.plan_count, + shapes_for_query_hash = + ISNULL(qr.shape_count, 1), + qi.query_id_list, + qi.plan_id_list, + ranked_on = + @ps_rank_metric + N' volatility', + volatility_score = + CASE @ps_rank_metric + WHEN N'duration' THEN ps.duration_volatility + WHEN N'physical reads' THEN ps.physical_reads_volatility + WHEN N'writes' THEN ps.writes_volatility + WHEN N'memory' THEN ps.memory_volatility + WHEN N'rows' THEN ps.rows_volatility + WHEN N'tempdb' THEN ps.tempdb_volatility + ELSE ps.cpu_volatility + END, + signals = + STUFF + ( + ISNULL + ( + N' | ' + + CASE + WHEN sw.cpu_ratio >= 20 + AND ps.max_cpu_ms >= 10 + AND sw.cpu_ratio >= sw.rows_ratio * 10 + THEN N'parameter sensitive (cpu ' + + CONVERT(nvarchar(20), CONVERT(bigint, sw.cpu_ratio)) + + N'x, rows ' + + CONVERT(nvarchar(20), CONVERT(bigint, sw.rows_ratio)) + + N'x)' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN sw.rows_ratio >= 20 + AND sw.cpu_ratio <= sw.rows_ratio * 2 + THEN N'row driven (cpu ' + + CONVERT(nvarchar(20), CONVERT(bigint, sw.cpu_ratio)) + + N'x, rows ' + + CONVERT(nvarchar(20), CONVERT(bigint, sw.rows_ratio)) + + N'x)' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN sw.duration_ratio >= 20 + AND ps.max_duration_ms >= 1000 + AND sw.cpu_ratio < 3 + THEN N'intermittent waits (duration ' + + CONVERT(nvarchar(20), CONVERT(bigint, sw.duration_ratio)) + + N'x, cpu ' + + CONVERT(nvarchar(20), CONVERT(decimal(5, 1), sw.cpu_ratio)) + + N'x)' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN sw.memory_ratio >= 10 + AND ps.max_memory_mb >= 8 + THEN N'memory grant swings (' + + CONVERT(nvarchar(20), CONVERT(bigint, sw.memory_ratio)) + + N'x)' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN sw.tempdb_ratio >= 10 + AND ps.max_tempdb_mb >= 8 + THEN N'tempdb swings (' + + CONVERT(nvarchar(20), CONVERT(bigint, sw.tempdb_ratio)) + + N'x)' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN ps.aborted_executions > 0 + THEN N'timed out or cancelled ' + + CONVERT(nvarchar(20), ps.aborted_executions) + + N' times' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN ps.exception_executions > 0 + THEN N'errored ' + + CONVERT(nvarchar(20), ps.exception_executions) + + N' times' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN vf.has_variants = 1 + THEN N'psp variants involved' + END, + N'' + ) + + ISNULL + ( + N' | ' + + CASE + WHEN qr.shape_count > 1 + THEN N'other plan shapes exist (' + + CONVERT(nvarchar(20), qr.shape_count) + + N' total, shape avg cpu ' + + CONVERT(nvarchar(20), CONVERT(decimal(19, 1), qr.min_shape_avg_cpu_ms)) + + N' to ' + + CONVERT(nvarchar(20), CONVERT(decimal(19, 1), qr.max_shape_avg_cpu_ms)) + + N' ms)' + END, + N'' + ), + 1, + 3, + N'' + ), + ps.total_executions, + ps.aborted_executions, + ps.exception_executions, + ps.min_cpu_ms, + ps.avg_cpu_ms, + ps.max_cpu_ms, + ps.cpu_volatility, + ps.min_duration_ms, + ps.avg_duration_ms, + ps.max_duration_ms, + ps.duration_volatility, + ps.min_rows, + ps.avg_rows, + ps.max_rows, + ps.rows_volatility, + ps.last_execution_time, + variance_metrics = + ( + SELECT + [memory/@min_mb] = ps.min_memory_mb, + [memory/@avg_mb] = ps.avg_memory_mb, + [memory/@max_mb] = ps.max_memory_mb, + [memory/@volatility] = ps.memory_volatility, + [physical_reads/@min_mb] = ps.min_physical_reads_mb, + [physical_reads/@avg_mb] = ps.avg_physical_reads_mb, + [physical_reads/@max_mb] = ps.max_physical_reads_mb, + [physical_reads/@volatility] = ps.physical_reads_volatility, + [writes/@min_mb] = ps.min_writes_mb, + [writes/@avg_mb] = ps.avg_writes_mb, + [writes/@max_mb] = ps.max_writes_mb, + [writes/@volatility] = ps.writes_volatility, + [tempdb/@min_mb] = ps.min_tempdb_mb, + [tempdb/@avg_mb] = ps.avg_tempdb_mb, + [tempdb/@max_mb] = ps.max_tempdb_mb, + [tempdb/@volatility] = ps.tempdb_volatility, + [parallelism/@max_dop] = ps.max_dop + FOR + XML + PATH(N'metrics'), + TYPE + ) + FROM #ps_shape_stats AS ps + JOIN #ps_interesting AS i + ON i.query_hash = ps.query_hash + AND i.query_plan_hash = ps.query_plan_hash + CROSS APPLY + ( + /* + max/min ratios with clamped mins, not (max - min) / avg: when a + third of executions are slow, the average inflates and the swing + reads small even when max/min is four orders of magnitude apart, + which is exactly the case this mode exists to catch + */ + VALUES + ( + ps.max_cpu_ms / + CASE + WHEN ps.min_cpu_ms < 0.001 + THEN 0.001 + ELSE ps.min_cpu_ms + END, + ps.max_duration_ms / + CASE + WHEN ps.min_duration_ms < 0.001 + THEN 0.001 + ELSE ps.min_duration_ms + END, + CONVERT(decimal(38, 6), ps.max_rows) / + CASE + WHEN ps.min_rows < 1 + THEN 1 + ELSE ps.min_rows + END, + ps.max_memory_mb / + CASE + WHEN ps.min_memory_mb < 0.001 + THEN 0.001 + ELSE ps.min_memory_mb + END, + ps.max_tempdb_mb / + CASE + WHEN ps.min_tempdb_mb < 0.001 + THEN 0.001 + ELSE ps.min_tempdb_mb + END + ) + ) AS sw (cpu_ratio, duration_ratio, rows_ratio, memory_ratio, tempdb_ratio) + LEFT JOIN #ps_representative_text AS rt + ON rt.query_hash = ps.query_hash + AND rt.query_plan_hash = ps.query_plan_hash + AND rt.rn = 1 + LEFT JOIN #ps_identifiers AS qi + ON qi.query_hash = ps.query_hash + AND qi.query_plan_hash = ps.query_plan_hash + LEFT JOIN #ps_shape_waits AS qw + ON qw.query_hash = ps.query_hash + AND qw.query_plan_hash = ps.query_plan_hash + LEFT JOIN #ps_variant_flags AS vf + ON vf.query_hash = ps.query_hash + AND vf.query_plan_hash = ps.query_plan_hash + LEFT JOIN #ps_query_rollup AS qr + ON qr.query_hash = ps.query_hash; + + IF @troubleshoot_performance = 1 + BEGIN + SET STATISTICS XML OFF; + + EXECUTE sys.sp_executesql + @troubleshoot_update, + N'@current_table nvarchar(100)', + @current_table; + END; + + /*Step 7: Final output with plans (dynamic SQL for OUTER APPLY)*/ + SELECT + @current_table = 'selecting parameter sensitivity results', + @sql = @isolation_level; + + IF @troubleshoot_performance = 1 + BEGIN + EXECUTE sys.sp_executesql + @troubleshoot_insert, + N'@current_table nvarchar(100)', + @current_table; + + SET STATISTICS XML ON; + END; + + SELECT + @sql += N' +SELECT + database_name = + @database_name, + start_date = + ' + + CASE + WHEN @timezone IS NOT NULL + THEN N'@start_date AT TIME ZONE @timezone' + ELSE N'SWITCHOFFSET(@start_date, @utc_offset_string)' + END + N', + end_date = + ' + + CASE + WHEN @timezone IS NOT NULL + THEN N'@end_date AT TIME ZONE @timezone' + ELSE N'SWITCHOFFSET(@end_date, @utc_offset_string)' + END + N', + o.object_name, + query_sql_text = + ( + SELECT + [processing-instruction(query)] = + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + o.query_sql_text COLLATE Latin1_General_BIN2, + NCHAR(31),N''?''),NCHAR(30),N''?''),NCHAR(29),N''?''),NCHAR(28),N''?''),NCHAR(27),N''?''),NCHAR(26),N''?''),NCHAR(25),N''?''),NCHAR(24),N''?''),NCHAR(23),N''?''),NCHAR(22),N''?''), + NCHAR(21),N''?''),NCHAR(20),N''?''),NCHAR(19),N''?''),NCHAR(18),N''?''),NCHAR(17),N''?''),NCHAR(16),N''?''),NCHAR(15),N''?''),NCHAR(14),N''?''),NCHAR(12),N''?''), + NCHAR(11),N''?''),NCHAR(8),N''?''),NCHAR(7),N''?''),NCHAR(6),N''?''),NCHAR(5),N''?''),NCHAR(4),N''?''),NCHAR(3),N''?''),NCHAR(2),N''?''),NCHAR(1),N''?''),NCHAR(0),N'''') + FOR XML + PATH(N''''), + TYPE + ), + query_plan = + TRY_CONVERT(xml, qp.query_plan), + ' + + CASE + WHEN @new = 1 + AND @query_store_waits_enabled = 1 + THEN N'o.top_waits, + ' + ELSE N'' + END + N'o.query_hash, + o.query_plan_hash, + o.query_count, + o.plan_count, + o.shapes_for_query_hash, + o.query_id_list, + o.plan_id_list, + o.ranked_on, + o.volatility_score, + o.signals, + o.total_executions, + o.aborted_executions, + o.exception_executions, + o.min_cpu_ms, + o.avg_cpu_ms, + o.max_cpu_ms, + o.cpu_volatility, + o.min_duration_ms, + o.avg_duration_ms, + o.max_duration_ms, + o.duration_volatility, + o.min_rows, + o.avg_rows, + o.max_rows, + o.rows_volatility, + last_execution_time = + ' + + CASE + WHEN @timezone IS NOT NULL + THEN N'o.last_execution_time AT TIME ZONE @timezone' + ELSE N'SWITCHOFFSET(o.last_execution_time, @utc_offset_string)' + END + N', + o.variance_metrics +FROM #ps_output AS o +OUTER APPLY +( + SELECT + qp0.* + FROM + ( + SELECT + n = ROW_NUMBER() OVER + ( + ORDER BY + qsp.last_execution_time DESC + ), + qsp.query_plan + FROM ' + @database_name_quoted + N'.sys.query_store_plan AS qsp + WHERE EXISTS + ( + SELECT + 1/0 + FROM #ps_id_staging AS s + WHERE qsp.plan_id = s.plan_id + AND o.query_hash = s.query_hash + AND o.query_plan_hash = s.query_plan_hash + ) + ) AS qp0 + WHERE qp0.n = 1 +) AS qp +ORDER BY + o.volatility_score DESC, + o.query_hash, + o.query_plan_hash OPTION(RECOMPILE);' + @nc10; IF @debug = 1 @@ -6852,7 +8750,7 @@ OPTION(RECOMPILE);' + @nc10; BEGIN RETURN; END; -END; /*End @find_high_impact*/ +END; /*End @find_parameter_sensitive*/ /* Get filters ready, or whatever @@ -6938,20 +8836,21 @@ BEGIN @work_end_utc ); - IF @df = 1 - BEGIN - SELECT - @where_clause += N'AND DATEPART(WEEKDAY, qsrs.last_execution_time) BETWEEN 1 AND 5' + @nc10; - END;/*df 1*/ - ELSE IF @df = 7 - BEGIN - SELECT - @where_clause += N'AND DATEPART(WEEKDAY, qsrs.last_execution_time) BETWEEN 2 AND 6' + @nc10; - END;/*df 7*/ - ELSE - BEGIN - RAISERROR('Warning: @workdays filter does not support @@DATEFIRST = %i, weekday filter skipped', 10, 1, @df) WITH NOWAIT; - END; + /* + Derive the weekday from the LOCAL execution time, not the raw UTC value, so + it stays consistent with the local work-hour window above: qsrs.last_execution_time + is UTC, so shift it back to local by -@utc_minutes_difference (embedded as a + literal because @utc_minutes_difference isn't in the shared @parameters list). + Without this, a query run on a local weekday morning that lands on the previous + UTC day (far-east time zones) was dropped as a weekend/wrong day. + DATEDIFF(DAY, 0, local) % 7 is @@DATEFIRST-independent (0 = Monday .. 4 = Friday, + since 1900-01-01 was a Monday), so one predicate covers every server instead of + only @@DATEFIRST 1 and 7 (the other values previously skipped the filter). + */ + SELECT + @where_clause += N'AND DATEDIFF(DAY, 0, DATEADD(MINUTE, ' + + CONVERT(nvarchar(20), -1 * @utc_minutes_difference) + + N', qsrs.last_execution_time)) % 7 BETWEEN 0 AND 4' + @nc10; IF @work_start_utc IS NOT NULL AND @work_end_utc IS NOT NULL @@ -7053,7 +8952,7 @@ OPTION(RECOMPILE);' + @nc10; ) EXECUTE sys.sp_executesql @sql, - N'@procedure_name_quoted sysname', + N'@procedure_name_quoted nvarchar(1024)', /* nvarchar(1024) to match the variable's declaration; sysname truncated a quoted 3-part name over 128 chars */ @procedure_name_quoted; IF @troubleshoot_performance = 1 @@ -9023,7 +10922,7 @@ BEGIN WHEN 'memory' THEN N'SUM(qsrs.avg_query_max_used_memory * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' WHEN 'tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END /* count_executions per interval is meaningful as a plain mean — it''s a count, not an average-of-averages. */ - WHEN 'executions' THEN N'AVG(qsrs.count_executions)' + WHEN 'executions' THEN N'AVG(CONVERT(float, qsrs.count_executions))' WHEN 'rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' WHEN 'total cpu' THEN N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)' WHEN 'total logical reads' THEN N'SUM(qsrs.avg_logical_io_reads * qsrs.count_executions)' @@ -9034,7 +10933,7 @@ BEGIN WHEN 'total tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)' END WHEN 'total rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions)' /* Waits and the fallback path — waits are per-interval totals so AVG is correct; fallback mirrors cpu path. */ - ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(waits.total_query_wait_time_ms)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END + ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(CONVERT(float, waits.total_query_wait_time_ms))' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END END + N' ) @@ -9139,7 +11038,7 @@ BEGIN WHEN 'duration' THEN N'SUM(qsrs.avg_duration * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' WHEN 'memory' THEN N'SUM(qsrs.avg_query_max_used_memory * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' WHEN 'tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END - WHEN 'executions' THEN N'AVG(qsrs.count_executions)' + WHEN 'executions' THEN N'AVG(CONVERT(float, qsrs.count_executions))' WHEN 'rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' WHEN 'total cpu' THEN N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)' WHEN 'total logical reads' THEN N'SUM(qsrs.avg_logical_io_reads * qsrs.count_executions)' @@ -9149,7 +11048,7 @@ BEGIN WHEN 'total memory' THEN N'SUM(qsrs.avg_query_max_used_memory * qsrs.count_executions)' WHEN 'total tempdb' THEN CASE WHEN @new = 1 THEN N'SUM(qsrs.avg_tempdb_space_used * qsrs.count_executions)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions)' END WHEN 'total rows' THEN N'SUM(qsrs.avg_rowcount * qsrs.count_executions)' - ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(waits.total_query_wait_time_ms)' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END + ELSE CASE WHEN @sort_order_is_a_wait = 1 THEN N'AVG(CONVERT(float, waits.total_query_wait_time_ms))' ELSE N'SUM(qsrs.avg_cpu_time * qsrs.count_executions) / NULLIF(SUM(CONVERT(float, qsrs.count_executions)), 0)' END END + N' ) @@ -12467,6 +14366,7 @@ Format numeric values based on @format_output IF ( @expert_mode = 1 + OR @log_to_table = 1 /* logging must populate every secondary table regardless of expert mode; each section's display and empty-message paths below are guarded by @log_to_table = 0, so opening these gates during a logging run emits no result sets */ OR ( @only_queries_with_hints = 1 @@ -12485,6 +14385,7 @@ BEGIN */ IF @expert_mode = 1 OR @only_queries_with_feedback = 1 + OR @log_to_table = 1 BEGIN IF EXISTS ( @@ -12588,6 +14489,7 @@ BEGIN IF @expert_mode = 1 OR @only_queries_with_hints = 1 + OR @log_to_table = 1 BEGIN IF EXISTS ( @@ -12667,6 +14569,7 @@ BEGIN IF @expert_mode = 1 OR @only_queries_with_variants = 1 + OR @log_to_table = 1 BEGIN IF EXISTS ( @@ -12810,6 +14713,7 @@ BEGIN END; /*End 2022 views*/ IF @expert_mode = 1 + OR @log_to_table = 1 BEGIN IF EXISTS ( @@ -13355,6 +15259,7 @@ BEGIN IF @new = 1 BEGIN IF @expert_mode = 1 + OR @log_to_table = 1 BEGIN IF EXISTS ( @@ -13735,6 +15640,7 @@ BEGIN END; /*End wait stats queries*/ IF @expert_mode = 1 + OR @log_to_table = 1 BEGIN SELECT @current_table = 'selecting query store options', @@ -14196,6 +16102,8 @@ BEGIN @find_high_impact, primary_window = @primary_window, + find_parameter_sensitive = + @find_parameter_sensitive, help = @help, debug = @@ -14434,7 +16342,8 @@ BEGIN '#requested_but_skipped_databases is empty'; END; - IF @find_high_impact = 0 + IF @find_high_impact = 0 + AND @find_parameter_sensitive = 0 BEGIN IF EXISTS ( @@ -14598,7 +16507,8 @@ BEGIN '#include_query_hashes is empty'; END; - IF @find_high_impact = 0 + IF @find_high_impact = 0 + AND @find_parameter_sensitive = 0 BEGIN IF EXISTS ( @@ -15011,7 +16921,8 @@ BEGIN '#database_query_store_options is empty'; END; - IF @find_high_impact = 0 + IF @find_high_impact = 0 + AND @find_parameter_sensitive = 0 BEGIN IF EXISTS @@ -15495,7 +17406,8 @@ BEGIN '#troubleshoot_performance is empty'; END; - IF @find_high_impact = 0 + IF @find_high_impact = 0 + AND @find_parameter_sensitive = 0 BEGIN IF EXISTS ( @@ -15876,6 +17788,298 @@ BEGIN END; END; + IF @find_parameter_sensitive = 1 + AND OBJECT_ID('tempdb..#ps_intervals') IS NOT NULL + BEGIN + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_intervals AS pi + ) + BEGIN + SELECT + table_name = + '#ps_intervals', + pi.* + FROM #ps_intervals AS pi + ORDER BY + pi.runtime_stats_interval_id + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_intervals is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_shape_stats AS ps + ) + BEGIN + SELECT + table_name = + '#ps_shape_stats', + ps.* + FROM #ps_shape_stats AS ps + ORDER BY + ps.query_hash, + ps.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_shape_stats is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_query_rollup AS qr + ) + BEGIN + SELECT + table_name = + '#ps_query_rollup', + qr.* + FROM #ps_query_rollup AS qr + ORDER BY + qr.query_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_query_rollup is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_interesting AS i + ) + BEGIN + SELECT + table_name = + '#ps_interesting', + i.* + FROM #ps_interesting AS i + ORDER BY + i.query_hash, + i.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_interesting is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_id_staging AS s + ) + BEGIN + SELECT + table_name = + '#ps_id_staging', + s.* + FROM #ps_id_staging AS s + ORDER BY + s.query_hash, + s.query_plan_hash, + s.plan_id + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_id_staging is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_representative_text AS rt + ) + BEGIN + SELECT + table_name = + '#ps_representative_text', + rt.* + FROM #ps_representative_text AS rt + ORDER BY + rt.query_hash, + rt.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_representative_text is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_object_staging AS os + ) + BEGIN + SELECT + table_name = + '#ps_object_staging', + os.* + FROM #ps_object_staging AS os + ORDER BY + os.query_hash, + os.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_object_staging is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_wait_staging AS ws + ) + BEGIN + SELECT + table_name = + '#ps_wait_staging', + ws.* + FROM #ps_wait_staging AS ws + ORDER BY + ws.query_hash, + ws.query_plan_hash, + ws.rn + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_wait_staging is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_shape_waits AS sw + ) + BEGIN + SELECT + table_name = + '#ps_shape_waits', + sw.* + FROM #ps_shape_waits AS sw + ORDER BY + sw.query_hash, + sw.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_shape_waits is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_variant_flags AS vf + ) + BEGIN + SELECT + table_name = + '#ps_variant_flags', + vf.* + FROM #ps_variant_flags AS vf + ORDER BY + vf.query_hash, + vf.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_variant_flags is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_identifiers AS qi + ) + BEGIN + SELECT + table_name = + '#ps_identifiers', + qi.* + FROM #ps_identifiers AS qi + ORDER BY + qi.query_hash, + qi.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_identifiers is empty'; + END; + + IF EXISTS + ( + SELECT + 1/0 + FROM #ps_output AS o + ) + BEGIN + SELECT + table_name = + '#ps_output', + o.* + FROM #ps_output AS o + ORDER BY + o.query_hash, + o.query_plan_hash + OPTION(RECOMPILE); + END; + ELSE + BEGIN + SELECT + result = + '#ps_output is empty'; + END; + END; + RETURN; /*Stop doing anything, I guess*/ END; /*End debug*/ RETURN; /*Yeah sure why not?*/ diff --git a/sp_QuickieStore/tests/README.md b/sp_QuickieStore/tests/README.md new file mode 100644 index 00000000..1f342282 --- /dev/null +++ b/sp_QuickieStore/tests/README.md @@ -0,0 +1,64 @@ +# sp_QuickieStore Tests + +**Run before and after any change to `sp_QuickieStore.sql`.** Compiling proves +very little here: the procedure is ~16,000 lines that assemble a large dynamic +SQL statement whose *shape* changes with almost every one of its 60 parameters. +The statement that breaks is built at run time from string fragments, so it only +fails when it executes. + +## Automated + +| Script | What it does | +| --- | --- | +| `run_tests.py` | Builds a Query Store scratch database, then runs a parameter matrix asserting each combination executes cleanly and reaches completion, plus bidirectional filter checks. 157 assertions. | + +``` +cd sp_QuickieStore/tests +python run_tests.py --server SQL2022 +``` + +Takes `--server` and `--password` (default `SQL2022` / the standard local sa +password). Expect `157`. + +## What it actually covers + +The matrix is the point. Every axis below rewrites the generated statement, and +each run *executes* what it built: + +- **All 39 `@sort_order` values** — the highest-value axis. Each builds a + different `ORDER BY`, and the wait sorts additionally join + `query_store_wait_stats`. +- **9 `@wait_filter` values**, **4 `@execution_type_desc`**, **4 `@query_type`**. +- The **`@expert_mode` x `@format_output` grid**, which rewrites the column list, + plus several sort orders combined with expert mode. +- **`@find_parameter_sensitive`**, the plan-shape volatility mode: ranking by + each volatility metric, the mode grids, floors, mode-conflict guard errors, + and a fixture procedure (`qs_sniff_proc`) skewed hard enough that the + `parameter sensitive` signal must fire on a real conviction. + +On top of that, **bidirectional** assertions prove the filters actually filter +rather than being silently ignored — `@query_text_search` on a known string vs +nonsense, `@query_type` partitioning proc from ad hoc, `@top`, and +`@execution_count` set impossibly high. Every absence assertion is paired with a +completion check so an errored or empty run cannot pass vacuously. + +## Fixture + +The harness creates its own `quickiestore_test` database with Query Store on, +runs a small varied workload (ad hoc queries at different costs plus a stored +procedure, so `@query_type` has both kinds to separate), flushes Query Store, and +drops the database in a `finally` block that runs even if assertions fail. +Nothing outside that database is touched. + +## Known client limitation: `@debug = 1` + +`@debug = 1` is exercised against a database with **no** Query Store data on +purpose. With data, debug mode returns the generated SQL as an **XML column** (so +it is clickable in SSMS), and **go-sqlcmd renders XML columns pathologically +slowly** — capturing the output takes minutes and looks exactly like the +procedure hanging, with the server parked in `ASYNC_NETWORK_IO` waiting for the +client to drain the result set. + +This is a client limitation, not a procedure defect: the same run is instant in +SSMS, and a non-debug run against the same populated database returns at full +width in well under a second. Do not "fix" this by widening timeouts. diff --git a/sp_QuickieStore/tests/run_tests.py b/sp_QuickieStore/tests/run_tests.py new file mode 100644 index 00000000..73c87eab --- /dev/null +++ b/sp_QuickieStore/tests/run_tests.py @@ -0,0 +1,578 @@ +""" +sp_QuickieStore assertion test harness +====================================== +sp_QuickieStore is ~16,000 lines that assemble a large dynamic SQL statement +whose SHAPE changes with almost every one of its 60 parameters: @sort_order +alone accepts 35+ values, each producing a different ORDER BY and column set, +and @wait_filter / @execution_type_desc / @query_type / @expert_mode / +@format_output each rewrite the statement again. + +That is where this procedure's bugs live. A parameter combination nobody has +run assembles SQL that is malformed or references a column that is not in that +shape, and it fails only at EXECUTION -- compiling the procedure proves nothing, +because the statement that breaks is built at runtime from string fragments. + +So the core of this harness is a parameter matrix: run the procedure across +every @sort_order, every @wait_filter, every @execution_type_desc and +@query_type, in both @expert_mode and @format_output states, and assert each +run executes cleanly and reaches completion. Every run executes the dynamic SQL +it just built, which is the only way to catch this class of defect. + +On top of that, a few BIDIRECTIONAL filter assertions (finding present when the +filter should match, absent when it should not) prove the filters actually +filter rather than being silently ignored. + +Fixture: the harness builds its own scratch database with Query Store enabled, +runs a small varied workload (ad hoc queries at different costs plus a stored +procedure, so @query_type has both kinds to separate), flushes Query Store, and +drops the database at the end. Nothing outside that database is touched. + +Usage: + python run_tests.py [--server SQL2022] [--password L!nt0044] + +Exits 1 if any assertion fails. +""" + +import argparse +import os +import re +import shlex +import subprocess +import sys + +TEST_DB = "quickiestore_test" + +# The footer result set the procedure always emits last. Its presence proves the +# run reached completion rather than dying midway, and is the positive control +# for every "no rows" / absence assertion. +DONE_MARKER = "brought to you by darling data!" + +# Every @sort_order the procedure documents. Each one builds a different +# ORDER BY (and for the wait sorts, joins query_store_wait_stats), so this is +# the highest-value axis in the matrix. +SORT_ORDERS = [ + "cpu", "logical reads", "physical reads", "writes", "duration", "memory", + "tempdb", "executions", "recent", "plan count by hashes", + "cpu waits", "lock waits", "locks waits", "latch waits", "latches waits", + "buffer latch waits", "buffer latches waits", "buffer io waits", + "log waits", "log io waits", "network waits", "network io waits", + "parallel waits", "parallelism waits", "memory waits", "total waits", + "rows", + "total cpu", "total logical reads", "total physical reads", "total writes", + "total duration", "total memory", "total tempdb", "total rows", + # the avg/average prefixes the help text says are also accepted + "avg cpu", "average duration", "avg tempdb", +] + +WAIT_FILTERS = [ + "cpu", "lock", "latch", "buffer latch", "buffer io", "log io", + "network io", "parallelism", "memory", +] + +EXECUTION_TYPES = ["regular", "aborted", "exception", "failed"] + +QUERY_TYPES = ["ad hoc", "adhoc", "proc", "procedure"] + + +def _sqlcmd_prefix(): + """The sqlcmd binary plus any connection args, overridable via environment + so one harness runs both locally and in CI. Locally SQLCMD_BIN defaults to + 'sqlcmd' on PATH and SQLCMD_CONN_ARGS is empty; CI sets SQLCMD_BIN to the + go-based sqlcmd and SQLCMD_CONN_ARGS to '-C -N disable' -- trust the + container's self-signed cert and disable encryption, which the modern Go + TLS stack needs to connect to the SQL Server 2017 container.""" + return [os.environ.get("SQLCMD_BIN", "sqlcmd")] + shlex.split( + os.environ.get("SQLCMD_CONN_ARGS", "")) + + +def _sqlcmd(server, password, sql, database="master", timeout=300): + """Run a batch and return (stdout, stderr) decoded as UTF-8. + + -y 200 truncates variable-length columns in the OUTPUT, which keeps the + captured text small: the main result set carries query_plan as full + ShowPlanXML and we only ever need enough of a column to detect errors and + count rows, never the whole plan. + """ + cmd = _sqlcmd_prefix() + [ + "-S", server, "-U", "sa", "-P", password, + "-d", database, + "-W", # trim trailing spaces + "-w", "65535", # do not wrap wide rows + "-y", "200", # truncate wide columns (plan XML) so rendering is fast + "-s", "\t", # tab delimiter + "-Q", sql, + ] + r = subprocess.run(cmd, capture_output=True, timeout=timeout) + return ((r.stdout or b"").decode("utf-8", errors="replace"), + (r.stderr or b"").decode("utf-8", errors="replace")) + + +def _esc(s): + """Escape a T-SQL single-quoted literal.""" + return s.replace("'", "''") + + +def find_sql_errors(text): + """Severity 16+ errors from either stream. go-sqlcmd reports SQL errors on + stdout, so both have to be checked. Msg 4060/911 ("Cannot open database") + are only Level 11 but mean the batch never ran, so catch those too.""" + if not text: + return [] + pattern = r"Msg (?:\d+, Level 1[6-9]|4060|911)[^\n]*" + return re.findall(pattern, text) + + +def run_qs(server, password, extra="", timeout=300): + """Run sp_QuickieStore against the scratch database, always naming it + explicitly, and return (stdout, combined-for-error-scanning).""" + sql = ("SET NOCOUNT ON; EXECUTE dbo.sp_QuickieStore " + "@database_name = '%s'%s;" % (_esc(TEST_DB), extra)) + out, err = _sqlcmd(server, password, sql, timeout=timeout) + return out, out + "\n" + err + + +def completed(stdout): + """True if the procedure reached its final footer result set.""" + return DONE_MARKER in stdout + + +def result_rows(stdout): + """Count of main result-set rows (each begins with the source column).""" + return sum(1 for line in stdout.splitlines() + if line.startswith("runtime_stats") or line.startswith("plan_forcing")) + + +class Results: + def __init__(self): + self.items = [] + + def check(self, group, name, condition, detail=""): + self.items.append({"group": group, "name": name, + "passed": bool(condition), "detail": detail}) + + @property + def passed(self): + return sum(1 for r in self.items if r["passed"]) + + @property + def failed(self): + return sum(1 for r in self.items if not r["passed"]) + + +FIXTURE_SQL = """ +SET NOCOUNT ON; +IF DB_ID(N'{db}') IS NOT NULL +BEGIN + ALTER DATABASE {db} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE {db}; +END; +CREATE DATABASE {db}; +ALTER DATABASE {db} SET QUERY_STORE = ON + (OPERATION_MODE = READ_WRITE, DATA_FLUSH_INTERVAL_SECONDS = 60, + INTERVAL_LENGTH_MINUTES = 1, QUERY_CAPTURE_MODE = ALL); +""".format(db=TEST_DB) + +# Second batch: runs inside the scratch database. Kept separate because it needs +# the database to exist and to be the connection context. +WORKLOAD_SQL = """ +SET NOCOUNT ON; +CREATE TABLE dbo.t +( + id integer NOT NULL IDENTITY PRIMARY KEY, + a integer NOT NULL, + b varchar(100) NOT NULL +); + +INSERT dbo.t WITH (TABLOCK) (a, b) +SELECT TOP (50000) ac1.column_id, REPLICATE('x', 50) +FROM sys.all_columns AS ac1 CROSS JOIN sys.all_columns AS ac2; + +EXECUTE (N'CREATE PROCEDURE dbo.qs_test_proc AS BEGIN SELECT c = COUNT_BIG(*) FROM dbo.t WHERE a % 7 = 0; END;'); + +DECLARE @i integer = 0, @c bigint; +WHILE @i < 12 +BEGIN + SELECT @c = COUNT_BIG(*) FROM dbo.t WHERE a > 5; + SELECT @c = SUM(CONVERT(bigint, a)) FROM dbo.t WHERE b LIKE 'x%'; + SELECT @c = COUNT_BIG(*) FROM dbo.t AS t1 JOIN dbo.t AS t2 ON t2.a = t1.a WHERE t1.id < 400; + EXECUTE dbo.qs_test_proc; + SET @i += 1; +END; + +-- Parameter sensitivity fixture for @find_parameter_sensitive: a skewed table +-- (group 1 has 100k rows, groups 2-501 have one each) and a procedure compiled +-- on a tiny group so the seek + lookup plan gets reused for the giant group. +-- One plan shape, four orders of magnitude of cpu swing, flat-ish row counts. +CREATE TABLE dbo.skew +( + id integer NOT NULL IDENTITY PRIMARY KEY, + grp integer NOT NULL, + pad char(200) NOT NULL +); + +INSERT dbo.skew WITH (TABLOCK) (grp, pad) +SELECT TOP (100000) 1, REPLICATE('x', 200) +FROM sys.all_columns AS ac1 CROSS JOIN sys.all_columns AS ac2; + +INSERT dbo.skew (grp, pad) +SELECT TOP (500) 1 + ROW_NUMBER() OVER (ORDER BY ac1.object_id), REPLICATE('y', 200) +FROM sys.all_columns AS ac1; + +CREATE INDEX ix_grp ON dbo.skew (grp); + +EXECUTE (N'CREATE PROCEDURE dbo.qs_sniff_proc (@grp integer) AS BEGIN SET NOCOUNT ON; DECLARE @c bigint, @p char(200); SELECT @c = COUNT_BIG(*) FROM dbo.skew AS s WHERE s.grp = @grp; SELECT TOP (10) @p = s2.pad FROM dbo.skew AS s2 WHERE s2.grp = @grp ORDER BY s2.pad; END;'); + +DECLARE @g integer = 0; +WHILE @g < 6 +BEGIN + EXECUTE dbo.qs_sniff_proc @grp = 5; + SET @g += 1; +END; +EXECUTE dbo.qs_sniff_proc @grp = 1; +EXECUTE dbo.qs_sniff_proc @grp = 1; +EXECUTE dbo.qs_sniff_proc @grp = 1; +EXECUTE dbo.qs_sniff_proc @grp = 301; +EXECUTE dbo.qs_sniff_proc @grp = 302; +EXECUTE dbo.qs_sniff_proc @grp = 303; + +EXECUTE sys.sp_query_store_flush_db; +""" + +CLEANUP_SQL = """ +SET NOCOUNT ON; +IF DB_ID(N'{db}') IS NOT NULL +BEGIN + ALTER DATABASE {db} SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE {db}; +END; +""".format(db=TEST_DB) + + +def build_fixture(server, password, R): + out, err = _sqlcmd(server, password, FIXTURE_SQL) + errs = find_sql_errors(out + "\n" + err) + R.check("Fixture", "scratch database created with Query Store on", + not errs, str(errs)) + if errs: + return False + + out, err = _sqlcmd(server, password, WORKLOAD_SQL, database=TEST_DB, + timeout=600) + errs = find_sql_errors(out + "\n" + err) + R.check("Fixture", "workload ran and Query Store flushed", not errs, str(errs)) + if errs: + return False + + out, _ = _sqlcmd(server, password, + "SET NOCOUNT ON; SELECT COUNT_BIG(*) FROM sys.query_store_query;", + database=TEST_DB) + captured = any(line.strip().isdigit() and int(line.strip()) > 0 + for line in out.splitlines()) + R.check("Fixture", "Query Store captured queries to analyze", + captured, "no queries captured; the matrix would pass vacuously") + return captured + + +def smoke_tests(server, password, R): + out, combined = run_qs(server, password) + R.check("Smoke", "default run: no severe SQL error", + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("Smoke", "default run: reached completion", + completed(out), "footer result set missing") + R.check("Smoke", "default run: returned query rows", + result_rows(out) > 0, "no result rows from a populated Query Store") + + out, combined = run_qs(server, password, ", @help = 1") + R.check("Smoke", "@help = 1: no severe SQL error", + not find_sql_errors(combined), str(find_sql_errors(combined))) + + # @debug is exercised against a database with no Query Store data on + # purpose. With data, debug mode returns the generated SQL as an XML column + # (so it is clickable in SSMS), and go-sqlcmd renders XML columns so slowly + # that capturing the output takes minutes and looks like a hang -- the + # server parks in ASYNC_NETWORK_IO waiting for the client to drain it. SSMS + # handles it fine; this is a client limitation, not a procedure defect, so + # we exercise the debug code path where it is capturable rather than skip it. + sql = "SET NOCOUNT ON; EXECUTE dbo.sp_QuickieStore @database_name = 'master', @debug = 1;" + out, err = _sqlcmd(server, password, sql) + R.check("Smoke", "@debug = 1: no severe SQL error", + not find_sql_errors(out + "\n" + err), + str(find_sql_errors(out + "\n" + err))) + + +def sort_order_matrix(server, password, R): + """Every @sort_order, each of which builds a different ORDER BY. This is the + highest-value axis: a bad sort order yields SQL that only fails at run time.""" + for so in SORT_ORDERS: + out, combined = run_qs(server, password, + ", @sort_order = '%s'" % _esc(so)) + errs = find_sql_errors(combined) + R.check("SortOrder", "@sort_order = '%s' executes cleanly" % so, + not errs, str(errs[:2])) + R.check("SortOrder", "@sort_order = '%s' reaches completion" % so, + completed(out), "footer missing") + + +def mode_matrix(server, password, R): + """@expert_mode and @format_output each rewrite the column list.""" + for expert in (0, 1): + for fmt in (0, 1): + extra = ", @expert_mode = %d, @format_output = %d" % (expert, fmt) + out, combined = run_qs(server, password, extra) + errs = find_sql_errors(combined) + label = "expert_mode=%d format_output=%d" % (expert, fmt) + R.check("Modes", "%s executes cleanly" % label, not errs, str(errs[:2])) + R.check("Modes", "%s reaches completion" % label, + completed(out), "footer missing") + + # A sort order combined with expert mode changes both ORDER BY and columns. + for so in ("cpu", "duration", "total waits", "plan count by hashes"): + out, combined = run_qs(server, password, + ", @sort_order = '%s', @expert_mode = 1" % _esc(so)) + errs = find_sql_errors(combined) + R.check("Modes", "@sort_order = '%s' + expert_mode executes cleanly" % so, + not errs, str(errs[:2])) + + +def filter_matrix(server, password, R): + """@wait_filter, @execution_type_desc and @query_type each add joins and + predicates that reshape the statement.""" + for wf in WAIT_FILTERS: + out, combined = run_qs(server, password, + ", @wait_filter = '%s'" % _esc(wf)) + errs = find_sql_errors(combined) + R.check("WaitFilter", "@wait_filter = '%s' executes cleanly" % wf, + not errs, str(errs[:2])) + R.check("WaitFilter", "@wait_filter = '%s' reaches completion" % wf, + completed(out), "footer missing") + + for et in EXECUTION_TYPES: + out, combined = run_qs(server, password, + ", @execution_type_desc = '%s'" % _esc(et)) + errs = find_sql_errors(combined) + R.check("ExecType", "@execution_type_desc = '%s' executes cleanly" % et, + not errs, str(errs[:2])) + + for qt in QUERY_TYPES: + out, combined = run_qs(server, password, + ", @query_type = '%s'" % _esc(qt)) + errs = find_sql_errors(combined) + R.check("QueryType", "@query_type = '%s' executes cleanly" % qt, + not errs, str(errs[:2])) + + +def bidirectional_tests(server, password, R): + """Prove the filters actually filter, rather than being silently ignored. + Each presence assertion is paired with an absence assertion on the same + filter, and every absence run is checked for completion so an errored or + empty run cannot pass vacuously.""" + # ---- @query_text_search -------------------------------------------- + out, combined = run_qs(server, password, + ", @query_text_search = 'qs_test_proc'") + R.check("Search", "@query_text_search on a known string: no severe error", + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("Search", "@query_text_search on a known string: reaches completion", + completed(out), "footer missing") + + out2, combined2 = run_qs(server, password, + ", @query_text_search = 'zzz_no_such_text_zzz'") + R.check("Search", "@query_text_search on nonsense: reaches completion " + "(positive control for the absence below)", + completed(out2), "footer missing") + R.check("Search", "@query_text_search on nonsense returns no query rows", + result_rows(out2) == 0, + "expected zero rows, got %d" % result_rows(out2)) + + # ---- @query_type separates the proc from the ad hoc queries -------- + out, combined = run_qs(server, password, ", @query_type = 'proc'") + proc_rows = result_rows(out) + R.check("Search", "@query_type = 'proc': reaches completion", + completed(out), "footer missing") + + out, combined = run_qs(server, password, ", @query_type = 'ad hoc'") + adhoc_rows = result_rows(out) + R.check("Search", "@query_type = 'ad hoc': reaches completion", + completed(out), "footer missing") + R.check("Search", "@query_type actually partitions proc vs ad hoc", + proc_rows > 0 and adhoc_rows > 0 and proc_rows != adhoc_rows, + "proc=%d adhoc=%d (expected both non-zero and different)" + % (proc_rows, adhoc_rows)) + + # ---- @top bounds the result set ------------------------------------ + out, combined = run_qs(server, password, ", @top = 1") + R.check("Top", "@top = 1: no severe error", + not find_sql_errors(combined), str(find_sql_errors(combined))) + R.check("Top", "@top = 1 returns at most one query row", + result_rows(out) <= 1, "got %d rows" % result_rows(out)) + + # ---- @execution_count filters out everything when set impossibly high + out, combined = run_qs(server, password, ", @execution_count = 1000000") + R.check("ExecCount", "@execution_count impossibly high: reaches completion", + completed(out), "footer missing") + R.check("ExecCount", "@execution_count impossibly high returns no rows", + result_rows(out) == 0, "got %d rows" % result_rows(out)) + + +PS_SUMMARY_MARKER = "multi_shape_query_hashes" + + +def ps_detail_rows(stdout): + """Detail rows from @find_parameter_sensitive output: one per plan shape, + each beginning with the analyzed database name.""" + return sum(1 for line in stdout.splitlines() + if line.startswith(TEST_DB + "\t")) + + +def parameter_sensitive_tests(server, password, R): + """@find_parameter_sensitive is a takeover mode like @find_high_impact: it + returns its own result sets (a summary plus one row per plan shape) and + never reaches the footer, so completion is proven by the summary result + set's header instead of DONE_MARKER. The fixture's qs_sniff_proc gives it + a real conviction to make: one plan shape whose cpu swings four orders of + magnitude while rows stay flat.""" + out, combined = run_qs(server, password, ", @find_parameter_sensitive = 1") + errs = find_sql_errors(combined) + R.check("ParamSensitive", "default run executes cleanly", not errs, str(errs[:2])) + R.check("ParamSensitive", "default run returns the summary result set", + PS_SUMMARY_MARKER in out, "summary header missing") + R.check("ParamSensitive", "default run returns detail rows", + ps_detail_rows(out) > 0, "no shapes surfaced from the skewed workload") + R.check("ParamSensitive", "the sniffed procedure is surfaced", + "qs_sniff_proc" in out, "qs_sniff_proc missing from output") + R.check("ParamSensitive", "the parameter sensitive signal fires", + "parameter sensitive (cpu" in out, "signals column missing the verdict") + + # Each of these picks a different volatility column to rank by; the wait + # sort falls back to cpu. 'tempdb' additionally exercises the 2017+ branch. + for so in ("duration", "memory", "rows", "tempdb", "physical reads", + "cpu waits"): + out, combined = run_qs(server, password, + ", @find_parameter_sensitive = 1, " + "@sort_order = '%s'" % _esc(so)) + errs = find_sql_errors(combined) + R.check("ParamSensitive", "@sort_order = '%s' executes cleanly" % so, + not errs and PS_SUMMARY_MARKER in out, str(errs[:2])) + + for expert in (0, 1): + for fmt in (0, 1): + extra = (", @find_parameter_sensitive = 1, @expert_mode = %d, " + "@format_output = %d" % (expert, fmt)) + out, combined = run_qs(server, password, extra) + errs = find_sql_errors(combined) + R.check("ParamSensitive", + "expert_mode=%d format_output=%d executes cleanly" + % (expert, fmt), + not errs and PS_SUMMARY_MARKER in out, str(errs[:2])) + + out, combined = run_qs(server, password, + ", @find_parameter_sensitive = 1, @top = 1") + R.check("ParamSensitive", "@top = 1 returns exactly one shape", + ps_detail_rows(out) == 1, "got %d rows" % ps_detail_rows(out)) + + out, combined = run_qs(server, password, + ", @find_parameter_sensitive = 1, " + "@execution_count = 1000000") + R.check("ParamSensitive", + "@execution_count impossibly high: summary still returned " + "(positive control for the absence below)", + PS_SUMMARY_MARKER in out, "summary header missing") + R.check("ParamSensitive", "@execution_count impossibly high returns no shapes", + ps_detail_rows(out) == 0, "got %d rows" % ps_detail_rows(out)) + + for extra, what in ( + (", @find_parameter_sensitive = 1, @find_high_impact = 1", + "@find_high_impact"), + (", @find_parameter_sensitive = 1, @get_all_databases = 1", + "@get_all_databases"), + (", @find_parameter_sensitive = 1, @log_to_table = 1", + "@log_to_table")): + out, combined = run_qs(server, password, extra) + R.check("ParamSensitive", "conflict with %s raises the guard error" % what, + "cannot be used with" in combined, "guard error missing") + + # DEBUG dumps: run against a scratch database with Query Store ON but no + # captured queries. master's Query Store is off, which bails out long + # before the mode block, so it cannot reach the new dump section. This + # reaches the mode with an empty Query Store (covering that path too), + # dumps every #ps_* table as its "is empty" row, and exercises the + # extended guards that skip the normal-path tables the mode never + # creates. With no XML rows, the go-sqlcmd rendering problem the other + # @debug test dodges does not apply here. + empty_db = TEST_DB + "_empty" + drop_empty = ("IF DB_ID(N'%s') IS NOT NULL BEGIN " + "ALTER DATABASE %s SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " + "DROP DATABASE %s; END;" % (empty_db, empty_db, empty_db)) + _sqlcmd(server, password, + drop_empty + + " CREATE DATABASE %s; ALTER DATABASE %s SET QUERY_STORE = ON " + "(OPERATION_MODE = READ_WRITE);" % (empty_db, empty_db)) + try: + sql = ("SET NOCOUNT ON; EXECUTE dbo.sp_QuickieStore " + "@database_name = '%s', @find_parameter_sensitive = 1, " + "@debug = 1;" % empty_db) + out, err = _sqlcmd(server, password, sql) + R.check("ParamSensitive", + "@debug = 1 on an empty Query Store: no severe SQL error", + not find_sql_errors(out + "\n" + err), + str(find_sql_errors(out + "\n" + err))) + R.check("ParamSensitive", "@debug = 1 reaches the mode's DEBUG dumps", + "#ps_shape_stats is empty" in out, + "new dump section not reached") + finally: + _sqlcmd(server, password, drop_empty) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--server", default="SQL2022") + ap.add_argument("--password", default="L!nt0044") + args = ap.parse_args() + + print("Running sp_QuickieStore assertion tests against %s..." % args.server) + print() + + out, _ = _sqlcmd(args.server, args.password, + "SET NOCOUNT ON; SELECT CASE WHEN OBJECT_ID(N'dbo.sp_QuickieStore', N'P') " + "IS NULL THEN 'MISSING' ELSE 'PRESENT' END;") + if "PRESENT" not in out: + print("ERROR: dbo.sp_QuickieStore is not installed in master on %s." + % args.server) + print("Install sp_QuickieStore.sql before running this harness.") + sys.exit(1) + + R = Results() + try: + if build_fixture(args.server, args.password, R): + smoke_tests(args.server, args.password, R) + sort_order_matrix(args.server, args.password, R) + mode_matrix(args.server, args.password, R) + filter_matrix(args.server, args.password, R) + bidirectional_tests(args.server, args.password, R) + parameter_sensitive_tests(args.server, args.password, R) + finally: + out, err = _sqlcmd(args.server, args.password, CLEANUP_SQL) + R.check("Fixture", "scratch database dropped", + not find_sql_errors(out + "\n" + err), "cleanup failed") + + for item in R.items: + status = "PASS" if item["passed"] else "FAIL" + detail = (" (%s)" % item["detail"]) if item["detail"] and not item["passed"] else "" + print(" [%s] %s: %s%s" % (status, item["group"], item["name"], detail)) + + print() + print("Results: %d passed, %d failed, %d total" + % (R.passed, R.failed, R.passed + R.failed)) + + if R.failed: + print() + print("FAILED TESTS:") + for item in R.items: + if not item["passed"]: + print(" %s: %s (%s)" % (item["group"], item["name"], item["detail"])) + sys.exit(1) + + print("All tests passed!") + + +if __name__ == "__main__": + main()