Name the lock resource's database in the DMV blocking sentinel (#1893) - #1900
Conversation
#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>
Review summaryWhat this does: the DMV blocking-snapshot collector now names the lock resource's database (not the blocked session's) for Parity: Verified independently of the tests:
One correctness note left inline: the new Worth a conscious yes/no, not a blocker: this also changes what's persisted (not just what's alerted on) for same-database Didn't flag any SQL-Server missing-index folklore per instructions. Tests, style ( |
| 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 |
There was a problem hiding this comment.
lock_type here is classified from der_b.wait_resource — sys.dm_exec_requests, one row per session — but resource_database_id is parsed from wt.resource_description — sys.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.
…database # Conflicts: # CHANGELOG.md
Review summaryWent through the DMV blocking sentinel change ( Correctness / edge cases checked:
Lite/Darling parity: Security: No dynamic SQL introduced (unlike the report-side collector's cursor/ Performance: The new Style: T-SQL matches the house style (uppercase keywords, lowercase non-abbreviated types, block comments only, trailing commas, 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. |
…#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>
Closes #1893.
#1876 made the two blocking collectors'
contentious_objectlabels agree at the incident identity, and corrected theblocked_process_reportsentinel 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 isdatabase_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 fromwait_resource. MS Learn documents every lock resource type's description as carrying adbid=<db-id>token:Keykeylock hobtid=<hobt-id> dbid=<db-id>PAGEpagelock fileid=<file-id> pageid=<page-id> dbid=<db-id> subresource=<...>RIDridlock fileid=<file-id> pageid=<page-id> dbid=<db-id>OBJECTobjectlock lockPartition=<...> objid=<obj-id> subresource=<...> dbid=<db-id>DATABASE/FILE/EXTENT/APPLICATION/METADATA/HOBT/ALLOCATION_UNITdbid=<db-id>So one parse covers every lock shape, where splitting
wait_resourcepositionally 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), andDB_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=andassociatedObjectId=all lack thedbid=substring.xactlock(optimized locking) carries twodbid=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 andRESOURCE_SEMAPHORErows have noTYPE:resource shape and nodbid=to read — theirresource_descriptionis 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 nodbid=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,CURSORmust 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 inpm1893_res(dbid 11).Same live pair, both expressions of
contentious_object:database_namecontentious_objectpm1893_ctxKEY: 11:72057594045726720 (8194443284a0)→ #1876 could only name it…database: pm1893_ctxpm1893_ctxUnresolved: key lock, database: pm1893_resAnd the report collector, fed the same lock where it could not resolve the object, produced
Unresolved: key lock, database: pm1893_res— byte-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 ONagainst SQL 2022 locally, and CI's SQL-validation matrix ran the modifiedinstall/56against SQL Server 2017, 2019, 2022 and 2025 — all four pass.Parity and schema
install/56_collect_dmv_blocking_snapshot.sqltakes 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 PROCEDUREshape, 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_idcolumn 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 realDedupKeyhashes through the real grouper.Watched red, each reverted, each breaking exactly its own test:
LCK_gate so latch rows get relabelleddbid=instead of the digitsLOWER()install/56behind (parity drift)The
LOWER()mutation initially survived — the fingerprint lower-cases before hashing, so casing could not split it, but the two collectors would have writtenKEY lockbesidekey lockfor the same contention. Added an assertion for it and re-verified red.dotnet teston the final head (devmoved 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)
(no metadata access),(page reallocated)) exists only on the report side, so when it is present the two labels still differ by that suffix and hash apart. Narrow (it needs the report side to have recorded a reason at all, and the common no-reason case is now byte-identical), but real. Dropping the reason from the identity while keeping it on the row is a product decision — two rows unresolvable for genuinely different reasons arguably are different situations — and it would be a fourth fingerprint transition, which should not ride this release.🤖 Generated with Claude Code