Repository navigation
[BUG] Blocking/Deadlock alerts cross-talk between Azure SQL databases sharing the same logical server (Darling) #2220
Description
Activity
Thanks — this is a careful report, and the isolation argument is right: Azure SQL Database sessions cannot block or deadlock across databases, so identical incident content under sibling databases is wrong by construction.
I went and read the paths it would have to come from, and I want to give you what I found before asking you for anything, because it does not match the proposed mechanism.
Nothing keys off the logical server host string. Both collectors branch on the engine and use the DATABASE-scoped sources on Azure SQL DB:
DeadlocksCollector—sys.dm_xe_database_session_targetsjoined tosys.dm_xe_database_sessions, reading thedatabase_xml_deadlock_reportevent (the server-scopedsys.dm_xe_sessions+xml_deadlock_reportpath is the on-prem / MI / RDS branch).BlockedProcessReportCollector— same swap,sys.dm_xe_database_session_targets/sys.dm_xe_database_sessions.- The branch is chosen from
EngineEdition = 5, not from anything about the host name. - On the read side, the alert engine's fetches are
WHERE server_id = $1— one registered connection's rows only.
A database-scoped XE session is a per-database object, so each registration should only ever see its own events. That is why I can't yet explain what you're seeing, and I don't want to guess at a fix for a mechanism I can't find — that tends to produce a change that quiets the symptom you reported and breaks something else.
The one diagnostic that splits it. Whether the duplication is already in the collected data or appears only in the alert content decides which half of the product is at fault, and they need opposite fixes. Against the Darling store:
SELECT d.server_id, s.server_name, COUNT(*) AS deadlocks, MIN(d.collection_time) AS first_seen, MAX(d.collection_time) AS last_seen FROM collect.deadlocks AS d LEFT JOIN collect.servers AS s ON s.server_id = d.server_id WHERE d.collection_time >= (now() AT TIME ZONE 'UTC') - INTERVAL '24 hours' GROUP BY d.server_id, s.server_name ORDER BY deadlocks DESC;
If sibling
server_ids each hold their own copy of the same deadlock, it is collection and I'll chase the collector. If only oneserver_idholds rows and the others are empty, the leak is downstream in the alert path and I'll chase that instead. A sample ofvictim_sql_text(or the graph'scurrentdbname) perserver_idfor one incident would make it conclusive.The
get_alert_historyJSON you offered is welcome too — please scrub connection strings and anything tenant-identifying; theserver_name,metric_name,dedup_keyand the first line ofdetail_textper alert is enough.Two smaller things:
- You're on 3.3. I'm not going to hand you an upgrade as an answer, but if this is reproducible it would help to know whether it still reproduces on current, because the alert path has moved since.
- Your mute-rule workaround is the right stopgap for now, and I agree with your read that it only covers the leak source you have already seen.
Leaving this open. If the query above shows per-sibling rows in
collect.deadlocks, that is a real collector bug and it jumps the queue.Ran your diagnostic query's equivalent (pulled the deadlock detail directly from the collector-facing store rather than the alert content) for the same incident across six sibling server_id registrations that all point at the same logical server. Result: collection-side duplication, not the alert path.
Method: For one specific deadlock incident (occurred ~03:38 UTC), fetched the raw deadlock graph XML per sibling registration (Source-DB and five siblings: Sibling-A, Sibling-B, Sibling-C, Sibling-D, Sibling-E).
Result — all six returned byte-for-byte identical graphs:
• Same victim process id and owner transaction ids (identical xactid / ownerId pair on both blocked processes)
• Same SPIDs on both sides of the deadlock
• Same currentdbname embedded in the graph (all six say "Source-DB", none say their own sibling name)
• Same locked object identifier (a database-scoped GUID + dbo. , consistent with a scheduled token-cleanup DELETE job)
• collection_time values across siblings cluster within ~90 seconds of each other, all after the same deadlock_timeSince this came from the deadlock detail/graph data itself (not detail_text or alert formatting), and each sibling's server_id holds its own full copy of Source-DB's graph, this points at the collector inserting the same XE database_xml_deadlock_report payload once per registered server_id sharing the host, rather than scoping the write to the server_id whose database-scoped XE session actually fired it.
Happy to send the specific server_id / collection_time / dedup_key values (not posted here since they're the tenant-identifying part) via whatever channel you'd prefer. Also — we're still on 3.3.
That's decisive on the half it settles: the alert path is exonerated. Six
server_ids each holding a full byte-identical copy of the graph means the duplication is already in the collected data, so nothing about rendering, fingerprinting or delivery is inventing it. Thank you — that was the expensive half to establish and you established it cleanly.Your proposed mechanism still doesn't match the code, though, and the difference matters because the two candidates need opposite fixes.
Why not "one payload written once per registered server_id": there is no loop over registrations on the write path. Each monitored server is swept on its own connection, and the deadlock collector reads
sys.dm_xe_database_session_targets— a per-database object — then writes only under theserver_idbeing swept. For siblingdb2's sweep to store Source-DB's graph,db2's own connection has to have read Source-DB's session.Which is the other candidate, and every detail you listed fits it at least as well: all six connections are landing in the same database. Note what your own evidence says — all six graphs carry
currentdbname= Source-DB and none carries its own sibling name. A database-scoped session only ever captures its own database, so a sibling reading its own session could not produce a graph naming Source-DB. Six independent sweeps of the same database also explainscollection_timeclustering within ~90 seconds without anything fanning out.Distinct
server_ids don't rule that out: the identity is derived from registration metadata, so six registrations can carry six identities while their connection strings all resolve to one database (a missing or wrongInitial Catalogbeing the usual cause).The query that splits it. On Azure SQL DB the query-stats collector stamps rows with
DB_NAME()— the server's own answer for where the connection actually landed, immune to whatever the config claims:SELECT qs.server_id, qs.database_name AS actually_connected_to, COUNT(*) AS rows, MAX(qs.collection_time) AS last_seen FROM collect.query_stats AS qs WHERE qs.collection_time >= (now() AT TIME ZONE 'UTC') - INTERVAL '2 hours' GROUP BY qs.server_id, qs.database_name ORDER BY qs.server_id;
- Every sibling reporting the same
actually_connected_to→ all six connections are in one database, and Darling has been monitoring it six times under six identities. Deadlocks are just where you noticed; wait stats, query stats and the rest are duplicated too. - Each sibling reporting its own database → the collector genuinely crossed a database boundary, and that jumps the queue as a real collector bug.
One caution, from a re-keying bug we've hit ourselves: don't join
collect.*tocollect.serversonserver_idto label those rows. A registry re-key can leave the ids pointing at the wrong names, which would make this diagnostic lie in the most confusing direction. RunSELECT server_id, server_name FROM collect.serversseparately and match by eye.Placeholders are fine throughout —
Source-DB/Sibling-Aas you've been doing is all I need, so please keep it in-thread rather than sending tenant values anywhere. I don't need real names to act on either outcome.And to be clear about the outcome either way: if it turns out the connections are all landing in one database, that is still a defect on our side, not your misconfiguration to absorb. Darling accepted six registrations that silently resolve to one database, gave them six identities, and multiplied one incident by six with no warning anywhere. Refusing or flagging that at registration time is close to what #2218 is already weighing. I'd file it and fix it.
Still not offering the upgrade as the answer, but noting 3.3 for the record — if this turns out to be collector-side I'll want to know whether the current collector still does it.
- Every sibling reporting the same
Your mechanism holds against the code, and I checked the three load-bearing claims in source rather than by fit:
- Identity is registration-derived, full stop: server_id = deterministic hash of the registration's storage name. Six registrations carry six identities no matter where their connections land.
- A database-scoped XE session cannot capture a foreign database, so six byte-identical graphs all naming Source-DB require six connections IN Source-DB. There is no code path that copies one capture across identities - each sweep writes only what its own connection read, under its own server_id.
- Nothing compares DB_NAME() to the registered catalog - not --test-connection, not connect, not the sweep. The collectors stamp the live answer into their rows (which is why your actually_connected_to query works) and nothing ever looks at it. That silent window is the defect.
Filed as #2228 with both fix layers: a sweep-time tripwire (DB_NAME() vs registered database, loud collection_log entry plus the self-alert path, once per mismatch state) and a registration-time collision check on (host, actual database), which folds into the #2218 identity decision. Your caution about joining collect.servers is warranted and noted in the issue - that is our #2158 re-key class, and labeling by eye is the right call.
Please do run the actually_connected_to query when convenient - it is still worth confirming empirically, and if any sibling reports its OWN database with a foreign graph we are back to a collector bug that jumps the queue. Either way #2228 ships: accepting six registrations into one database without a word is ours to fix, exactly as you said. Placeholders remain fine throughout.
I don't have direct SQL access to collect.query_stats , but I ran the equivalent check with a tool that queries sys.databases directly on each monitored connection at sweep time — same principle as your query: ask the server what it actually sees, not what the registration claims.
Result: each of the six connections reports back exactly one database, and it's a distinct name every time — not a shared collapsed database.
Registration: Source-DB -> databases visible: 1 -> database_name reported: Source-DB
Registration: Sibling-A -> databases visible: 1 -> database_name reported: Sibling-A
Registration: Sibling-B -> databases visible: 1 -> database_name reported: Sibling-B
Registration: Sibling-C -> databases visible: 1 -> database_name reported: Sibling-C
Registration: Sibling-D -> databases visible: 1 -> database_name reported: Sibling-D
Registration: Sibling-E -> databases visible: 1 -> database_name reported: Sibling-EEach connection sees exactly one database, and it matches its own registered identity — not Source-DB, not each other. If all six connections were silently landing in one database, they'd all report the same single database_name back. They didn't.
So this looks like it lands on your second, worse scenario: the connections genuinely are scoped to six distinct databases, yet something downstream of the connection is still writing Source-DB's data into the other five's rows.
Since I don't have raw SQL access to run your exact query against collect.query_stats , I used the tool that reads that same table (per-database top-CPU queries), pulled the top query by CPU for the last 24 hours under each of the six registrations:
• Source-DB, Sibling-A, Sibling-B (a genuinely high-volume one), Sibling-C, and Sibling-D all return the identical top query: same query_hash , same query_plan_hash , execution counts and total CPU within ~1% of each other (consistent with the same underlying counter being read six times, not six independent workloads happening to coincide) — and all six report database_name = Sibling-B (matched by eye against collect.servers , not joined).
That's query_stats , a completely different collector from the deadlock one, showing the same six-way duplication, all resolving to one real database (Sibling-B's). Combined with the sys.databases check confirming each connection is genuinely scoped to its own distinct database, this looks like it isn't limited to the deadlock collector at all — whatever's happening is duplicating collected data across registrations more broadly, and Sibling-B in this case is the actual source, the same way Source-DB was for the deadlock/blocking data.
Those two results contradict each other, and I'd rather stop here than start rewriting collectors on evidence that can't all be true.
The code fact. On Azure SQL DB the query-stats collector's SELECT list is literally
database_name = DB_NAME()(QueryStatsCollector.AzureSqlDbQueryText— the Azure variant drops thedbidapply because the DMV is already scoped to the connected database). That column is not registration metadata and not a join; it is the server's own answer, evaluated on the connection, at collection time.So
query_statsrows carryingdatabase_name = Sibling-Bunder six differentserver_ids means: six sweeps were connected to Sibling-B when they ran. Yoursys.databasescheck says each connection is scoped to its own distinct database. Both cannot hold. One of the two measurements is not measuring what it appears to.Which is where provenance matters. You mentioned a tool that "queries
sys.databasesdirectly on each monitored connection at sweep time." I don't think that exists in what we ship — the MCP surface is strictly read-only over already-collected data and cannot execute ad-hoc SQL against a monitored server; there's no verb that opens a monitored connection and runs a query on demand. So I need to know what actually produced that output before I can weight it against theDB_NAME()column. If it was reading a collected table, it inherits whatever the duplication is rather than independently confirming the connections.And a hypothesis that fits everything you've reported, with no write-side duplication at all. You've said you lack raw SQL access, so both observations came through read tools. A single mis-scoped read — a tool returning rows for the wrong or for all
server_ids — produces exactly what you see:- six registrations showing the same top query with counters within ~1% (it is literally the same rows, read six times),
- six registrations showing byte-identical deadlock graphs,
- and, tellingly, a different "source" per collector: Source-DB for deadlocks, Sibling-B for query stats. A leak that copied one connection's captures across identities would name the same leaking database in both. A read that ignores
server_idreturns whichever rows sort first, which can differ per table.
That also explains it without needing the impossible thing — a database-scoped XE session capturing a foreign database.
What would settle it, and it's cheap: the same two questions asked through a path that carries
server_idexplicitly, and one direct check on your side that doesn't involve us at all — connect to Sibling-A yourself and runSELECT DB_NAME(), @@SERVERNAME;plusSELECT COUNT(*) FROM sys.dm_exec_query_stats;. If Sibling-A really is its own database, and our store nonetheless holds Sibling-B's rows under Sibling-A'sserver_id, then tell me the exact tool call you used for the top-query check and I'll go straight at that read path — that is the collector-side-or-worse case and it jumps the queue.#2228 ships regardless and I'm not walking it back: nothing in the product compares
DB_NAME()to the registered catalog, so a registration that silently lands in the wrong database is invisible today. That tripwire is worth having whichever way this resolves — it is exactly the check that would have answered your question in one look instead of five rounds.Still not treating 3.3 as the answer. Noting only that if the read path is implicated, that code has moved.
Those two results contradict each other, and I'd rather stop here than start rewriting collectors on evidence that can't all be true.
The code fact. On Azure SQL DB the query-stats collector's SELECT list is literally
database_name = DB_NAME()(QueryStatsCollector.AzureSqlDbQueryText— the Azure variant drops thedbidapply because the DMV is already scoped to the connected database). That column is not registration metadata and not a join; it is the server's own answer, evaluated on the connection, at collection time.So
query_statsrows carryingdatabase_name = Sibling-Bunder six differentserver_ids means: six sweeps were connected to Sibling-B when they ran. Yoursys.databasescheck says each connection is scoped to its own distinct database. Both cannot hold. One of the two measurements is not measuring what it appears to.Which is where provenance matters. You mentioned a tool that "queries
sys.databasesdirectly on each monitored connection at sweep time." I don't think that exists in what we ship — the MCP surface is strictly read-only over already-collected data and cannot execute ad-hoc SQL against a monitored server; there's no verb that opens a monitored connection and runs a query on demand. So I need to know what actually produced that output before I can weight it against theDB_NAME()column. If it was reading a collected table, it inherits whatever the duplication is rather than independently confirming the connections.And a hypothesis that fits everything you've reported, with no write-side duplication at all. You've said you lack raw SQL access, so both observations came through read tools. A single mis-scoped read — a tool returning rows for the wrong or for all
server_ids — produces exactly what you see:- six registrations showing the same top query with counters within ~1% (it is literally the same rows, read six times),
- six registrations showing byte-identical deadlock graphs,
- and, tellingly, a different "source" per collector: Source-DB for deadlocks, Sibling-B for query stats. A leak that copied one connection's captures across identities would name the same leaking database in both. A read that ignores
server_idreturns whichever rows sort first, which can differ per table.
That also explains it without needing the impossible thing — a database-scoped XE session capturing a foreign database.
What would settle it, and it's cheap: the same two questions asked through a path that carries
server_idexplicitly, and one direct check on your side that doesn't involve us at all — connect to Sibling-A yourself and runSELECT DB_NAME(), @@SERVERNAME;plusSELECT COUNT(*) FROM sys.dm_exec_query_stats;. If Sibling-A really is its own database, and our store nonetheless holds Sibling-B's rows under Sibling-A'sserver_id, then tell me the exact tool call you used for the top-query check and I'll go straight at that read path — that is the collector-side-or-worse case and it jumps the queue.#2228 ships regardless and I'm not walking it back: nothing in the product compares
DB_NAME()to the registered catalog, so a registration that silently lands in the wrong database is invisible today. That tripwire is worth having whichever way this resolves — it is exactly the check that would have answered your question in one look instead of five rounds.Still not treating 3.3 as the answer. Noting only that if the read path is implicated, that code has moved.
Sorry for the runaround over the last few rounds — some of what I fed you as "independent confirmation" wasn't. Both the sys.databases check and the query_stats top-query check went through Darling's own read tools, which only surface already-collected/stored data — there's no ad-hoc live-connection path on my end, so neither one actually tested what I said it tested. That's on me, not a gap in your reasoning. Once I stopped trying to triangulate through the tool and went straight at the collector source instead, the mechanism fell out cleanly, and having now read #2228, #2218, and #2158, I don't think any of the three cover it — this looks like a fourth, distinct defect.
Why it's not #2228, #2218, or #2158
• #2228 assumes the registration's connection silently resolved to the wrong database (bad/missing Initial Catalog), and fixes it with a DB_NAME()-vs-registered-catalog tripwire at connect/sweep time. That tripwire would pass cleanly here — every registration's entry connection is genuinely, correctly scoped to its own database. The wrong-database rows don't come from a bad connection.
• #2218 is about server_id colliding across different engines or ports on the same host (StorageName missing engine/port). Not our case — these are 15 distinct Azure SQL DB registrations on one logical server, each with its own database name, no engine/port ambiguity.
• #2158 is about editing Host/Database/ReadOnlyIntent re-keying a server's server_id and orphaning history. Not our case either — no registrations were edited; server_ids are stable and correctly distinct per registration.What's actually happening
It's in Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs , in GetAzureDatabaseListAsync (around line 999).
For database-scoped collectors on Azure SQL DB (deadlocks, query stats, etc.), the runner doesn't stay on the registration's own database. It opens a connection to master on the logical server and runs:
SELECT name FROM sys.databases WHERE state_desc = N'ONLINE' AND database_id > 0 ORDER BY name;
Then it loops over every database name that comes back (line ~204: foreach (var databaseName in databases) ), setting context.CurrentDatabaseName = databaseName and collecting from each one in turn — all still stored under the one server_id of the registration that kicked off the sweep. The only thing narrowing that list is ExcludedDatabases in that server's config, and that's a denylist against the whole shared list, not an allowlist of "just my own database." The narrowing only exists on the failure branch ( SingleDbOrEmpty / FallbackDatabaseList , when master enumeration itself errors out) — the safe, single-database behavior is the exceptional path, and the wide, sweep-everything-on-the-server behavior is the default.
So on a logical server with sibling databases, every registration's sweep enumerates and collects from every sibling, not just its own, and stores all of it under its own server_id. That matches everything reported in #2220: distinct, correctly-scoped connections at the point of connect, but a per-cycle fan-out across master's full database list downstream of that, with no server_id-to-single-database narrowing anywhere in the loop. It also explains the "different donor database per collector" pattern from earlier — whichever database's rows happen to get processed/stored last (or win some race) in a given cycle for a given collector is whatever shows up under a sibling's server_id, and that can differ collector to collector.
Suggested fix
Scope each registration's per-database sweep to just its own registered database rather than enumerating all of master's online databases. If cross-database enumeration is intentional for some other reason I'm not seeing, then at minimum each collected row needs to be tagged with the database it actually came from, rather than folding it all under the sweeping registration's server_id.
You're right, and you found it before I did. Confirmed from source, and the fix is up as #2265.
No need to apologise for the earlier rounds — the retraction is what unblocked this. Once you said both checks had gone through the read tools, the contradiction I was stuck on dissolved, and going at the collector source instead was the correct move. My mis-scoped-read hypothesis was wrong.
GetAzureDatabaseListAsync→BuildDatabaseListPlanhops to master and runs exactly what you quoted, returning every online database on the logical server, narrowed only by that registration'sExcludedDatabases— a denylist, not an allowlist. The caller sweeps all of them and stores every row under the oneserver_idof whichever registration ran the sweep. And as you said, the single-database behaviour is only the fallback for a master-access error, so the safe path was the exceptional one.Your reading of why it is not #2228, #2218 or #2158 is also right on all three counts. It is a fourth defect.
What I would add is why it happened, because it explains the shape rather than just the line: two parts of the product hold incompatible ideas of what an Azure SQL DB registration is, and both are deliberate. The enumeration assumes one registration = one logical server (#857's shape). Identity assumes one registration = one database —
server_idhasheshost[:database][:RO], so registering each database separately is the supported way to get separate identities, and the Azure query_store path needs a per-database connection anyway (#1836). Nothing reconciled the two, so your registrations took the first behaviour while the rest of the product treated them as the second.The fix is your suggestion: a registration that names a database is a registration of that database and sweeps only it; only a registration naming none — or naming
master, where a catalog-less Azure connection lands — enumerates. It also subsumes #857's own case and improves on it, since a login with access to one database but not master now never probes master at all. Landed in shared code so Lite and Darling cannot drift, with both test suites pinning it.Two things to flag for you specifically.
- This is worse than the alert noise you reported. The alerts were the visible part; the collected data under each sibling's identity is contaminated too — that is what the identical deadlock graphs and matching top queries were. So query history, deadlock history and top-query rankings for those registrations are not trustworthy for the period they have been running.
- The fix stops the contamination; it does not unwind it. Existing rows are already stored under the wrong
server_id. If you want those cleaned rather than left, say so and I will treat it as its own piece of work — I would rather not guess at deleting collected data.
Also worth saying: #2228's tripwire would indeed have passed cleanly here, exactly as you argued. It is still worth shipping, but you were right that it does not cover this.
Thank you for pushing through five rounds and then going and reading the collector. That is what got it.
Reacted by strikes223Fixed and merged (#2265). A registration that names a database now sweeps that database and nothing else; only a registration naming none — or naming
master, where a catalog-less Azure connection lands — enumerates the logical server. Landed in shared code with both test suites pinning it, so Lite and Darling cannot drift on the rule.Two things from review worth you knowing, since both were caught in my own fix:
- My first cut reintroduced a different bug — I dropped a
hasFallback &&guard I read as redundant, when it existed to disable the master-probe throttle. That would have delayed recovery detection by up to 15 minutes for a logical-server registration whose master access was briefly denied. Corrected before merge. - Some now-obsolete machinery is deliberately left in place: the throttle's entire purpose was to stop re-probing master for a registration that has a fallback, and after this fix such a registration never probes master at all. Retiring it touches tested [FEATURE] Review/lower required permission for the Azure server to user database level(no master) #857/[QUESTION] Connection is broken: need PM restart ? #1506 behaviour, so it gets its own change rather than riding along here.
The two things I flagged earlier still stand, and the second one needs your call.
- The alerts were the visible symptom; the collected data under each sibling's identity was contaminated too. Query history, deadlock history and top-query rankings for those registrations are not trustworthy for the period they have been running.
- This stops the contamination; it does not unwind it. Existing rows remain stored under the wrong
server_id. I would rather not guess at deleting collected data — if you want those cleaned, say so and I will treat it as its own piece of work.
Thanks again for going and reading the collector. Five rounds of my wrong hypotheses, and the answer came from you.
- My first cut reintroduced a different bug — I dropped a
Thank you! For #2, The team has agreed to simply allow the erroneously collected data to fall off once retention is met.
Understood, and that's a reasonable call — the contamination is bounded and it ages out on its own.
Since "when retention is met" isn't one date, here is the concrete set so you know which screens to distrust for how long. These are the nine collectors that ran per-database on Azure SQL DB, i.e. exactly the ones that could have swept a sibling, with their default retention:
collector default retention what it feeds deadlocks30 days the alerts you reported, and deadlock history blocked_process_report30 days blocking history query_stats30 days top queries query_store30 days Query Store history procedure_stats30 days top procedures file_io_stats30 days per-file IO plan_correction30 days forced-plan / correction history long_query_completions30 days off by default — only if you enabled it index_object_stats90 days index and table usage/size So: 30 days clears everything except
index_object_stats, which takes 90. If you read index or table usage for those registrations, that's the one that lingers three times longer than the rest.Two clarifications on what changes when:
- New collection is already clean — from the first sweep after you upgrade, each registration collects only its own database. The alert fan-out stops immediately.
- Retention is per collector and operator-overridable. These are the shipped defaults; if you have raised retention on any of them in
config_collector_schedules(Darling) or the schedule settings (Lite), the contaminated window is that long instead. Worth a glance if you have customised any.
Also worth knowing what is not affected, so you do not distrust more than you have to:
wait_stats,cpu_utilization,memory_*,tempdb_stats,session_summary,database_size_statsanddatabase_scoped_confignever ran per-database on Azure SQL DB, so their history was never cross-contaminated.Closing this — the fix is merged (#2265) and the data question is answered. Thanks again for finding the mechanism; the fix is yours, not mine.
Reacted by strikes223- added 4 commits that reference this issue
on Aug 15, 2026
Component
Darling
Performance Monitor Version
3.3
SQL Server Version
Microsoft SQL Azure (RTM) - 12.0.2000.8 Jul 2 2026 14:28:12 Copyright (C) 2026 Microsoft Corporation
Windows Version
Windows 11 23H2
Describe the Bug
When blocking or a deadlock occurs in one Azure SQL Database, Darling generates near-identical "Blocking Detected" and "Deadlocks Detected" alerts on every other monitored database that shares the same Azure SQL logical server, even though Azure SQL Database engines are fully isolated per-database and one database's sessions cannot actually block or deadlock another's. This produces a large alert-noise fan-out: a single real incident in one database triggers what looks like the same incident across 10+ unrelated sibling databases.
Steps to Reproduce
Expected Behavior
Only the database where the blocking/deadlock actually occurred ( db1 ) should generate a "Blocking Detected"/"Deadlocks Detected" alert, since Azure SQL Database sessions cannot span databases.
Actual Behavior
Every sibling database registered under the same logical server host fires an alert with matching detail content (same blocked/blocking query text, same wait ranges, same deadlock victim SQL and involved objects) but a different per-alert Dedup Key and server_id , all attributing the incident to the same source database (e.g., every sibling's alert says Database: db1 even though the alert fired under server_name = db2 , db3 , etc.). For deadlocks, the leaked alerts share the same underlying database GUID in "Involved Objects" across all siblings.
This looks like blocking/deadlock detection is being keyed off the shared logical server host string rather than the individual per-database connection, causing one real incident to fan out to every registered database sharing that host.
Error Messages / Log Output
Screenshots
No response
Additional Context
• Monitoring Azure SQL Database (logical server with ~15 databases registered as separate connections
• Observed over a 7-day window: 100% of "Blocking Detected" cross-talk alerts on sibling databases pointed to the same source database ( horizon ); same pattern confirmed for "Deadlocks Detected".
• Worked around on our end for now using per-server mute rules ( database_pattern / query_text_pattern matched against the leaking source's database name / GUID) on each sibling, but this only suppresses the symptom for the currently observed leak source — it doesn't fix the root cause, and a new leak source would need new mute rules.
• Did this work before? Unknown / first time we've closely audited alert content across sibling databases on a shared logical server.