Skip to content

A deadlock inside a stored procedure shows 'Proc [Database Id = N Object Id = M]' instead of the procedure name — the resolution is never attempted #3307

Closed

Description

@erikdarlingdata

What

A deadlock whose statement sits inside a stored procedure renders the procedure as SQL Server's unresolved placeholder rather than its name. Reported from the field on a live alert:

Database:          <a tenant database>
Victim SQL:        Proc [Database Id = 7 Object Id = 1790404501]
Processes:         SPID 251 (victim) vs SPID 256
Involved Objects:  <db>.dbo.<table>

Every other field in that alert resolved. Involved Objects names the actual table, the database resolved, the SPIDs resolved — and the one field an on-call engineer reads first to know what code deadlocked is an object id.

Why it happens

AlertContextBuilders.cs:165 renders the stored value verbatim:

item.Fields.Add(("Victim SQL", TruncateText(p.Row.VictimSqlText)));

Proc [Database Id = N Object Id = M] is what SQL Server itself writes into the deadlock graph when it cannot resolve the name in the capturing session's context. We store that string and display it unchanged.

Nothing in the tree parses procname at all — a repo-wide search over the alerting, collector and service projects returns no hits. So this is not a resolution that fails; it is a resolution that is never attempted.

Two routes, and the collection-time one looks better

Resolve at collection time. The collector is already connected to the target when it reads the deadlock event, and OBJECT_NAME(object_id, database_id) is valid cross-database from any context — so OBJECT_NAME(1790404501, 7) is one cheap call per unresolved frame. Store the resolved name; this is accurate regardless of how recently the procedure ran.

Or join what is already stored. collect.module_map already maps sql_handle to a module name, keyed (server_id, sql_handle) — that machinery exists from #1568/#2737 and is populated from recent procedure_stats (the service logs an upsert count each pass). Deadlock graph frames carry sqlhandle alongside procname, so the join needs no new collection.

The tradeoff is coverage: module_map only knows modules that appeared in procedure_stats recently, so a procedure that deadlocked but has not run enough to be captured would miss. The OBJECT_NAME route has no such dependency. The join is cheaper to build; the collection-time lookup is correct more often.

Whatever the route, degrade to the raw form

If resolution fails — object dropped between the deadlock and the lookup, or the monitoring login cannot see it — the field must keep showing Proc [Database Id = 7 Object Id = 1790404501] rather than going blank or saying "unknown". The object id is a worse answer than a name and a much better answer than nothing: it is still resolvable by hand.

Worth checking while in here

The same placeholder shape appears in blocking reports and in sys.dm_exec_requests-derived surfaces for the same reason. If the resolution lands as a shared helper rather than inline in the deadlock path, those get it for free — but confirm they are actually affected rather than assuming, since the blocked-process report carries its own text.

Activity

  1. erikdarlingdata commented on Sep 11, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling

    Reframing this: it is a regression from the prior art this was modeled on, not a gap nobody thought about. sp_HumanEventsBlockViewer resolves it, deliberately and in three separate places, and the technique is worth porting rather than reinventing.

    What the reference actually does

    From DarlingData/sp_HumanEvents/sp_HumanEventsBlockViewer.sql:

    query_text =
        CASE
            WHEN kheb.query_text
                 LIKE @inputbuf_bom + N'Proc |[Database Id = %' ESCAPE N'|'
            THEN
                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( /* same two SUBSTRINGs */ )
            ELSE kheb.query_text
        END

    Three things that matter and that my original write-up above got wrong or missed:

    It resolves to schema.object, not just the object name. OBJECT_SCHEMA_NAME(...) + '.' + OBJECT_NAME(...). That matters for consistency: the same alert's Involved Objects field already renders schema-qualified, so an unqualified procedure name would be the odd one out in its own message.

    The [ must be escaped, or the predicate silently matches nothing. [ opens a character class in LIKE, so LIKE N'Proc [Database Id = %' is a range expression, not a literal. The reference writes N'Proc |[Database Id = %' ESCAPE N'|'. A port that omits this does not error — it just never fires, and the field keeps showing object ids while a test written against a hand-built string may well pass.

    There is a BOM to allow for. @inputbuf_bom is prepended to the pattern because the text can begin with a byte-order mark. Same failure mode: omit it and the LIKE misses every affected row without complaining.

    Both of those are silent no-ops rather than errors, which is exactly how a "we ported it" change ships without working.

    Where it applies here, and where it may not

    Erik's report is a deadlock, where the placeholder arrives in the graph's procname attribute — an XML attribute, so no BOM. The BOM handling belongs to the blocked-process-report path, where the text comes from inputbuf. This product collects both (deadlocks and blocked_process_reports), so both need the resolution and only one needs the BOM tolerance. Worth confirming per surface rather than applying one shape to both.

    Note also that the reference repeats this predicate at three sites in one file. If a port inlines it per surface here it will drift the same way; one helper with the pattern, the escape, the offsets and the BOM in a single place is the shape that survives.

    Correcting my own suggestion above

    I proposed OBJECT_NAME(object_id, database_id) "or join module_map". The module_map route is the weaker one and I should not have offered it as an equal: it depends on the procedure having appeared in procedure_stats recently, and the reference does not need that. The prior art settles the question — parse the placeholder, resolve schema and name against the target. Drop the module_map idea.

  2. erikdarlingdata commented on Sep 11, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling

    Correcting my previous comment on a point that would break a port. I wrote:

    Erik's report is a deadlock, where the placeholder arrives in the graph's procname attribute — an XML attribute, so no BOM. The BOM handling belongs to the blocked-process-report path.

    Both halves are wrong, and sp_BlitzLock — a deadlock-only tool — is the proof.

    The placeholder is in <inputbuf>, not procname

    sp_BlitzLock.sql line 4013 applies the resolution to d.inputbuf, and the surrounding projection is unambiguously the deadlock process row: last_batch_started, lock_mode, transaction_count, client_app, host_name, login_name, isolation_level, client_option_1/2.

    This product's own deadlock fixtures agree — the graph XML carries the statement in an element, e.g. DarlingIncidentFingerprintTests.cs:

    <process id="process1" spid="55"><inputbuf>UPDATE x</inputbuf></process>

    So VictimSqlText comes from <inputbuf> element text, which is exactly where SQL Server writes Proc [Database Id = N Object Id = M].

    Which means the BOM handling DOES apply to deadlocks

    sp_BlitzLock uses @inputbuf_bom on the deadlock path. Element text can begin with a byte-order mark; an XML attribute was the wrong mental model and it is the reason I talked myself out of the guard.

    The consequence of my error, had it been implemented: the LIKE misses every BOM-prefixed row, silently, and the field keeps showing object ids. Combined with the unescaped-[ trap this is the second way to ship a "done" port that never fires — and I had just finished warning about that class while removing one of the two defences.

    So both references converge completely

    sp_HumanEventsBlockViewer sp_BlitzLock
    predicate `LIKE @inputbuf_bom + N'Proc [Database Id = %' ESCAPE N'
    source query_text d.inputbuf
    output OBJECT_SCHEMA_NAME(...) + N'.' + OBJECT_NAME(...) identical
    offsets Database Id = +14, Object Id = +12 identical

    Two implementations, same predicate, same escape, same BOM allowance, same schema-qualified result. There is no variant to choose between — port it as written.

    Applies to the blocked-process-report surface too, which already parses <inputbuf> here, so the same helper serves both rather than one shape per surface.

  3. added a commit that references this issue on Sep 11, 2026
  4. erikdarlingdata commented on Sep 11, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling

    Fixed by #3310, merged to dev as 4fe105e. Closing explicitly — this repo merges to dev but defaults to main, so closing keywords in a PR body never fire.

  5. added a commit that references this issue on Sep 11, 2026
  6. added a commit that references this issue on Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions