Skip to content

[BUG] Blocking/Deadlock alerts cross-talk between Azure SQL databases sharing the same logical server (Darling) #2220

Description

@strikes223

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

  1. Register multiple Azure SQL Databases that share one logical server (e.g.,  myserver.database.windows.net:db1 ,  myserver.database.windows.net:db2 , ... each as a separate monitored connection with its own display name) with Darling.
  2. Wait for real blocking or a deadlock to occur in one of the databases (e.g.,  db1 ).
  3. Check the alert history ( get_alert_history  / alert feed) for the other sibling databases ( db2 ,  db3 , ...) registered under the same logical server.

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

(N/A — this is alert content duplication, not an error; happy to attach  get_alert_history  JSON samples showing the duplicate detail_text across sibling  server_name  values if helpful)

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.

Activity

  1. erikdarlingdata commented on Aug 12, 2026

    @erikdarlingdata
    Owner

    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_targets joined to sys.dm_xe_database_sessions, reading the database_xml_deadlock_report event (the server-scoped sys.dm_xe_sessions + xml_deadlock_report path 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 one server_id holds rows and the others are empty, the leak is downstream in the alert path and I'll chase that instead. A sample of victim_sql_text (or the graph's currentdbname) per server_id for one incident would make it conclusive.

    The get_alert_history JSON you offered is welcome too — please scrub connection strings and anything tenant-identifying; the server_name, metric_name, dedup_key and the first line of detail_text per 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.

  2. strikes223 commented on Aug 12, 2026

    @strikes223
    Author

    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_time 

    Since 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.

  3. erikdarlingdata commented on Aug 12, 2026

    @erikdarlingdata
    Owner

    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 the server_id being swept. For sibling db2'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 explains collection_time clustering 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 wrong Initial Catalog being 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.* to collect.servers on server_id to 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. Run SELECT server_id, server_name FROM collect.servers separately and match by eye.

    Placeholders are fine throughout — Source-DB/Sibling-A as 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.

  4. erikdarlingdata commented on Aug 12, 2026

    @erikdarlingdata
    Owner

    Your mechanism holds against the code, and I checked the three load-bearing claims in source rather than by fit:

    1. 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.
    2. 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.
    3. 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.

  5. strikes223 commented on Aug 12, 2026

    @strikes223
    Author

    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-E

    Each 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.

  6. erikdarlingdata commented on Aug 12, 2026

    @erikdarlingdata
    Owner

    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 the dbid apply 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_stats rows carrying database_name = Sibling-B under six different server_ids means: six sweeps were connected to Sibling-B when they ran. Your sys.databases check 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.databases directly 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 the DB_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_id returns 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_id explicitly, and one direct check on your side that doesn't involve us at all — connect to Sibling-A yourself and run SELECT DB_NAME(), @@SERVERNAME; plus SELECT 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's server_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.

  7. strikes223 commented on Aug 14, 2026

    @strikes223
    Author

    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 the dbid apply 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_stats rows carrying database_name = Sibling-B under six different server_ids means: six sweeps were connected to Sibling-B when they ran. Your sys.databases check 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.databases directly 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 the DB_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_id returns 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_id explicitly, and one direct check on your side that doesn't involve us at all — connect to Sibling-A yourself and run SELECT DB_NAME(), @@SERVERNAME; plus SELECT 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's server_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.

  8. erikdarlingdata commented on Aug 14, 2026

    @erikdarlingdata
    Owner

    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 → BuildDatabaseListPlan hops to master and runs exactly what you quoted, returning every online database on the logical server, narrowed only by that registration's ExcludedDatabases — a denylist, not an allowlist. The caller sweeps all of them and stores every row under the one server_id of 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_id hashes host[: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.

    1. 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.
    2. 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.

  9. erikdarlingdata commented on Aug 14, 2026

    @erikdarlingdata
    Owner

    Fixed 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:

    The two things I flagged earlier still stand, and the second one needs your call.

    1. 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.
    2. 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.

  10. strikes223 commented on Aug 14, 2026

    @strikes223
    Author

    Thank you! For #2, The team has agreed to simply allow the erroneously collected data to fall off once retention is met.

  11. erikdarlingdata commented on Aug 14, 2026

    @erikdarlingdata
    Owner

    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
    deadlocks 30 days the alerts you reported, and deadlock history
    blocked_process_report 30 days blocking history
    query_stats 30 days top queries
    query_store 30 days Query Store history
    procedure_stats 30 days top procedures
    file_io_stats 30 days per-file IO
    plan_correction 30 days forced-plan / correction history
    long_query_completions 30 days off by default — only if you enabled it
    index_object_stats 90 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_stats and database_scoped_config never 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.

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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions