Skip to content

Name the lock resource's database in the DMV blocking sentinel (#1893) - #1900

Merged
erikdarlingdata merged 2 commits into
devfrom
fix/1893-dmv-resource-database
Jul 31, 2026
Merged

Name the lock resource's database in the DMV blocking sentinel (#1893)#1900
erikdarlingdata merged 2 commits into
devfrom
fix/1893-dmv-resource-database

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Closes #1893.

#1876 made the two blocking collectors' contentious_object labels agree at the incident identity, and corrected the blocked_process_report sentinel to name the lock RESOURCE's database rather than the blocked session's. The DMV snapshot side could not follow: its normalization runs in C# against a stored row whose only database name is database_name, which the collector writes as the blocked session's database. A cross-database lock is held in one database by a session running in another, so for exactly that case the two sides still disagreed and one contended object still raised two alerts.

The parsing shape, and its documented basis

The resource's database id comes from sys.dm_os_waiting_tasks.resource_description, not from wait_resource. MS Learn documents every lock resource type's description as carrying a dbid=<db-id> token:

lock type documented format
Key keylock hobtid=<hobt-id> dbid=<db-id>
PAGE pagelock fileid=<file-id> pageid=<page-id> dbid=<db-id> subresource=<...>
RID ridlock fileid=<file-id> pageid=<page-id> dbid=<db-id>
OBJECT objectlock lockPartition=<...> objid=<obj-id> subresource=<...> dbid=<db-id>
DATABASE / FILE / EXTENT / APPLICATION / METADATA / HOBT / ALLOCATION_UNIT all likewise end with or contain dbid=<db-id>

So one parse covers every lock shape, where splitting wait_resource positionally would need a branch per resource type. CHARINDEX(N'dbid=', …) then digits-only up to the first non-digit (PATINDEX(N'%[^0-9]%', tail + N'.'), the appended . guaranteeing a terminator at end of string), and DB_NAME() runs in the same query — which is the whole reason this had to move server-side: the id was always sitting in the row, but nothing downstream could turn it into a name.

No documented token can false-match: databasePrincipalId=, hobtid=, objid=, pageid=, fileid=, classid= and associatedObjectId= all lack the dbid= substring. xactlock (optimized locking) carries two dbid= tokens for the same database; the first wins.

The lock type still comes from wait_resource, using the report collector's classifier verbatim (KEY, OBJECT, RID, PAGE in that order, else the leading token upper-cased and capped at 32), so both sides produce the same token for the same lock. A test pins that ordering against both definitions.

Restricted to LCK_% waits that have a wait resource. Latch and RESOURCE_SEMAPHORE rows have no TYPE: resource shape and no dbid= to read — their resource_description is a bare <db-id>:<file-id>:<page> or a latch class — so they keep byte-identically what they had. Gating on the wait TYPE rather than sniffing the string is also honest about what this is: a lock-resource parse. Where no dbid= is found the row keeps its old raw value rather than being relabelled with a database nobody verified.

Scope held: no object resolution

Per the decision, the snapshot sweep still does not resolve the object behind a KEY, PAGE or RID lock. That is the per-database lookup the report side needs a server-side cursor and #1865's permission screen to do safely, and this collector runs on a far tighter cadence — naming the database is a string parse, resolving the object is a cross-database metadata read per row. A test pins the absence (sys.partitions, sys.dm_db_page_info, sp_executesql, CURSOR must not appear).

Live evidence (SQL Server 2022)

A real cross-database blocking pair: blocked session running in pm1893_ctx (dbid 13), contended KEY lock held in pm1893_res (dbid 11).

resource_description = keylock hobtid=72057594045726720 dbid=11 id=lock1e5334d3f80 mode=X associatedObjectId=72057594045726720
session database     = pm1893_ctx (13)

Same live pair, both expressions of contentious_object:

database_name contentious_object
before (dev) pm1893_ctx KEY: 11:72057594045726720 (8194443284a0)#1876 could only name it …database: pm1893_ctx
after (#1893) pm1893_ctx Unresolved: key lock, database: pm1893_res

And the report collector, fed the same lock where it could not resolve the object, produced Unresolved: key lock, database: pm1893_resbyte-identical, so the two fingerprint once.

(Incidentally: a live XE session on that box captured the same pair and did resolve it to dbo.locktarget. That asymmetry is the deliberate scope decision above, not a defect — when one collector can name the object and the other does not try, they legitimately differ.)

The Dashboard twin was additionally validated with SET PARSEONLY ON against SQL 2022 locally, and CI's SQL-validation matrix ran the modified install/56 against SQL Server 2017, 2019, 2022 and 2025 — all four pass.

Parity and schema

install/56_collect_dmv_blocking_snapshot.sql takes the identical change, guarded by a test that reads the file, so the SQL Server store cannot drift from Lite's and Darling's. It uses the idempotent create-stub-then-ALTER PROCEDURE shape, so re-running the installer picks up the new body.

Query text only — no schema moved. No upgrade-folder work and no store migration: the collector writes the same 19 columns with a better value in one of them, pinned by a test asserting no wait_resource / resource_database_id column appeared.

Churn

This is the third and final blocking-fingerprint transition in this release, after #1865 and #1876. DMV-sourced incidents for cross-database locks re-fire once against their new key; same-database locks are unaffected because the resource database and the session database are the same value.

Tests

Paired suites (DmvResourceDatabaseSentinelTests / DarlingDmvResourceDatabaseSentinelTests), 11 tests each, pinned independently so editing one app's copy alone fails a build. The fingerprint tests are behavioral: they take the literal strings both collectors produced for the one live lock and compare real DedupKey hashes through the real grouper.

Watched red, each reverted, each breaking exactly its own test:

mutation red
revert the DMV sentinel (pre-#1893 state) 3
name the SESSION database instead of the resource's 2
drop the LCK_ gate so latch rows get relabelled 1
take the rest of the string after dbid= instead of the digits 1
reorder the lock-type classifier (PAGE before KEY) 1
emit the lock word without LOWER() 1
leave install/56 behind (parity drift) 1
make the read-side normalizer rewrite report-form labels 1
Darling mirror: revert the DMV sentinel 3

The LOWER() mutation initially survived — the fingerprint lower-cases before hashing, so casing could not split it, but the two collectors would have written KEY lock beside key lock for the same contention. Added an assertion for it and re-verified red.

  • dotnet test on the final head (dev moved once under this branch; merged in, a link-reference-only CHANGELOG collision resolved by keeping all sides): Lite 1849 passed / 0 failed; Darling 3783 passed / 0 failed / 211 skipped (gated-live only). Installer.Tests not run.
  • dotnet build deprecated/Dashboard/Dashboard.csproj: 0 warnings, 0 errors.
  • dotnet build PerformanceMonitor.sln -t:Rebuild: 0 warnings, 0 errors.

Filed rather than shipped (per the standing rule)

🤖 Generated with Claude Code

#1876 made the two blocking collectors' labels agree at the incident identity
and corrected the report side's sentinel to name resource_database_id. The DMV
side could not follow: its normalization runs in C# against a stored row whose
only database name is database_name -- the blocked SESSION's database. A
cross-database lock is held in one database by a session running in another, so
that case still produced two fingerprints for one object.

The resource's database id now comes from sys.dm_os_waiting_tasks'
resource_description rather than from wait_resource: MS Learn documents every
lock resource type's description as carrying a dbid= token -- keylock, pagelock,
ridlock, objectlock, databaselock, filelock, extentlock, applicationlock,
metadatalock, hobtlock, allocunitlock -- so one parse covers every lock shape
where splitting wait_resource positionally needs a branch per type. DB_NAME()
runs in the same query, which is the whole reason this had to move server-side.

Scope held per the decision: the sweep still does NOT resolve the object behind
a KEY/PAGE/RID lock. That is the per-database lookup the report side needs a
cursor and #1865's permission screen for, and this collector runs on a far
tighter cadence. Restricted to LCK_% waits with a wait resource, so latch and
RESOURCE_SEMAPHORE rows keep byte-identically what they had; where no dbid= is
found the old raw value is kept.

Live on SQL 2022 against a real cross-database KEY lock: resource_description
read 'keylock hobtid=72057594045726720 dbid=11 id=lock... mode=X', the session
ran in db 13, and the same pair now yields 'Unresolved: key lock, database:
pm1893_res' from BOTH collectors, byte for byte. Query text only -- no schema,
no upgrade folder. install/56 takes the identical change with a drift-guard
test. Third and final fingerprint transition of this release. Residue: #1898.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review summary

What this does: the DMV blocking-snapshot collector now names the lock resource's database (not the blocked session's) for KEY/PAGE/RID locks it can't resolve to an object, by parsing dbid= out of sys.dm_os_waiting_tasks.resource_description. This makes its sentinel byte-identical to blocked_process_report's, so a cross-database lock fingerprints once instead of twice. install/56 (the deprecated Dashboard's hand-maintained twin) gets the identical change, with a test that reads the file to keep it honest.

Parity: DmvBlockingSnapshotCollector.cs lives in the shared PerformanceMonitor.Collectors library, so Lite and Darling consume the same SQL — the only real drift surface is install/56, which is updated and pinned. Good.

Verified independently of the tests:

  • The new lock_type classifier (KEY, OBJECT, RID, PAGE, then leading-token fallback) is a verbatim match of BlockedProcessReportCollector.cs's own classifier — checked the actual source, not just the test's assumption.
  • The digit-only parse (PATINDEX(N'%[^0-9]%', d.tail + N'.')) correctly handles a missing terminator and a non-digit-starting tail (LEFT(..., 0)''NULLIFNULLTRY_CONVERTNULL), so a malformed/absent dbid= falls through to the old raw-value fallback rather than erroring or mislabeling.
  • Correctly gated to LCK[_]% waits with a non-empty wait_resource, so latch/RESOURCE_SEMAPHORE rows are untouched.
  • No dynamic SQL, no new stored columns, no injection surface.

One correctness note left inline: the new resparse APPLY sources lock_type from der_b.wait_resource (one row per session, from sys.dm_exec_requests) but resource_database_id from wt.resource_description (one row per waiting task/exec_context_id, from sys.dm_os_waiting_tasks). For a parallelized blocked session with more than one concurrently-waiting worker thread, those two sources can describe different resources, so a row could pair one thread's lock type with a different thread's database. Narrow — needs a parallel plan with divergent per-thread lock waits — but new: before this PR, database-naming never touched resource_description, so this cross-source combination didn't exist previously.

Worth a conscious yes/no, not a blocker: this also changes what's persisted (not just what's alerted on) for same-database LCK_ waits — every parseable KEY/PAGE/RID row now stores the generic Unresolved: ... lock, database: X sentinel instead of the raw wait_resource (hobt id / lock hash / page id), which grids and the get_blocking MCP tools read straight off contentious_object. That mirrors the precedent blocked_process_report already set (its own final SELECT has stored the same sentinel, not the raw resource, since #1865/#1876), so it reads as an intentional parity call rather than an oversight — just flagging it since it's a step beyond "cross-database only," which is how the PR description frames the change.

Didn't flag any SQL-Server missing-index folklore per instructions. Tests, style ( /* */ comments, AND alignment, column = expr aliasing) and CHANGELOG entry all look consistent with the project's conventions.

Comment on lines +157 to +176
SELECT
resource_database_id =
TRY_CONVERT
(
integer,
NULLIF(LEFT(d.tail, PATINDEX(N'%[^0-9]%', d.tail + N'.') - 1), N'')
),
lock_type =
CASE
WHEN der_b.wait_resource LIKE N'%KEY: %' THEN N'KEY'
WHEN der_b.wait_resource LIKE N'%OBJECT: %' THEN N'OBJECT'
WHEN der_b.wait_resource LIKE N'%RID: %' THEN N'RID'
WHEN der_b.wait_resource LIKE N'%PAGE: %' THEN N'PAGE'
ELSE LEFT(UPPER(LEFT(der_b.wait_resource, CHARINDEX(N':', der_b.wait_resource + N':') - 1)), 32)
END
FROM
(
SELECT
tail = SUBSTRING(wt.resource_description, CHARINDEX(N'dbid=', wt.resource_description) + 5, 10)
) AS d

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lock_type here is classified from der_b.wait_resourcesys.dm_exec_requests, one row per session — but resource_database_id is parsed from wt.resource_descriptionsys.dm_os_waiting_tasks, one row per waiting task / exec_context_id.

For a parallel-plan blocked session with more than one concurrently-waiting worker thread (ecid > 0), each wt row can carry a different resource_description while der_b.wait_resource is the same session-level value joined to every one of those wt rows (there's no exec_context_id on dm_exec_requests to disambiguate). If two threads of the same blocked session are simultaneously waiting on different lock types and/or different databases, a row can end up combining resparse.lock_type classified from one thread's resource with resparse.resource_database_id parsed from a different thread's resource_description — a type/database pairing that never actually coexisted on the wire.

This is narrow (needs a parallel plan with genuinely divergent per-thread lock waits) and it's not a regression of existing behavior for lock_type/wait_resource (that was already session-level, pre-#1893). But it is new for the database id, since before this PR nothing here read resource_description at all, so this particular cross-source mismatch didn't exist. Given the whole point of this change is that the resource's database is "authoritative when present," might be worth confirming both fields are describing the same wait — e.g. gate this APPLY (and the one in install/56) on wt.exec_context_id = 0, or otherwise tie it to whichever thread der_b.wait_resource reflects.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review summary

Went through the DMV blocking sentinel change (PerformanceMonitor.Collectors/DmvBlockingSnapshotCollector.cs, install/56_collect_dmv_blocking_snapshot.sql, and both test suites) with a focus on correctness, Lite/Darling parity, security, and performance. No blocking issues found.

Correctness / edge cases checked:

  • dbid= parse: SUBSTRING(..., CHARINDEX(N'dbid=', ...) + 5, 10)PATINDEX(N'%[^0-9]%', d.tail + N'.') correctly isolates only the digit run even when the digits run to the end of the captured substring (the appended . guarantees a terminator so PATINDEX never returns 0 and LEFT(..., -1) never underflows).
  • NULL/missing resource_description and rows with no dbid= token are all handled via TRY_CONVERT/NULLIF/the CHARINDEX(...) > 0 guard — no risk of a runtime error, and such rows correctly fall through to the pre-existing ELSE der_b.wait_resource raw fallback rather than being mislabeled.
  • The WHEN objparse.object_id IS NOT NULL branch still wins first, so the new resparse sentinel only ever fires for the KEY/PAGE/RID (and unresolved OBJECT-parse) cases it's meant for — no interference with the existing OBJECT-lock resolution path.
  • Latch/RESOURCE_SEMAPHORE rows are excluded via the wt.wait_type LIKE N'LCK[_]%' gate on resparse itself (not string-sniffing), so they're provably byte-identical to before, matching the PR's stated scope.
  • xactlock's double dbid= token: CHARINDEX finds the first occurrence, and the two DBs are documented to agree for that case, so first-wins is correct.
  • Verified byte-for-byte that the lock-type classifier (KEY/OBJECT/RID/PAGE ordering + fallback) and the three sentinel literals are copied verbatim from BlockedProcessReportCollector's classifier/sentinel — this is the crux of the fix (fingerprint parity) and it checks out.

Lite/Darling parity: DmvBlockingSnapshotCollector.cs lives in the shared PerformanceMonitor.Collectors project, referenced by both Lite/PerformanceMonitorLite.csproj and Darling/PerformanceMonitor.Darling.Storage/...csproj, so there's no drift risk between the two apps for this collector — confirmed the paired test suites (Lite.Tests/DmvResourceDatabaseSentinelTests.cs, Darling/Darling.Tests/DarlingDmvResourceDatabaseSentinelTests.cs) pin identical expectations. install/56_collect_dmv_blocking_snapshot.sql (the deprecated Dashboard's hand-maintained twin) carries the identical query change, diffed it line-by-line against the shared collector and it matches, and is guarded by a drift test that reads the file contents directly.

Security: No dynamic SQL introduced (unlike the report-side collector's cursor/sp_executesql path) — this is pure static string parsing (CHARINDEX/PATINDEX/SUBSTRING/TRY_CONVERT) over a DMV column, so no injection surface.

Performance: The new OUTER APPLY is a cheap per-row scalar string parse against an already-materialized sys.dm_os_waiting_tasks row; no new I/O, no new cross-database access, consistent with the PR's stated goal of keeping this sweep on its existing tight cadence.

Style: T-SQL matches the house style (uppercase keywords, lowercase non-abbreviated types, block comments only, trailing commas, column = expr aliasing).

Nice, thorough test coverage and clear documentation of the scope boundary (no object resolution added, consistent with the #1865 cadence tradeoff already established for this collector). No changes requested.

@erikdarlingdata
erikdarlingdata merged commit 4adf323 into dev Jul 31, 2026
9 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/1893-dmv-resource-database branch July 31, 2026 04:00
erikdarlingdata added a commit that referenced this pull request Jul 31, 2026
…#1888 exposed

#1897 made live-test cleanup verified rather than swallowed, and #1900 landed
the DMV resource-database work; both merge cleanly apart from adjacent
CHANGELOG entries, which are kept side by side.

Re-verifying the gated-live suite on the merged tree under this PR's raised
worker settings turned three TimescaleSupportTests compression tests red every
run - and they passed alone, and passed as a whole class. The helper that hands
those tests a policy which cannot fire created and parked it as TWO autocommit
statements, so the scheduler could take the job in between. Parking a job that
has already launched does not recall the run in flight (#1874), and that run
evaluates its body when it gets a worker: under full-suite load, late enough
that the test's rows have landed, so it compresses chunks the test is about to
count.

Not a new break - the same test fails intermittently at the OLD worker settings
(1 of 2 full runs measured), which is how it stayed green on CI. This PR makes
it deterministic from both directions: more slots make the launch reliable, more
parallel load widens the launch-to-execute gap.

Fixed with the lever the product already pulls for retention policies (#1705):
create and park in ONE transaction, so the bgw_job row stays invisible until it
already reads scheduled = false and the scheduler, a separate backend, can never
see it armed.

Three consecutive full gated-live runs on fresh databases at the raised sizing
went from 3 failures every time to 3983 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant