From afe6dd66fd70b5bc0f194fcff804baae3877439e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:46:57 -0400 Subject: [PATCH] Pre-filter query snapshot requests into #temp on Azure SQL DB (#857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #861. The DB_ID() predicate in the WHERE clause wasn't enough — the OUTER APPLYs to sys.dm_exec_sql_text and sys.dm_exec_text_query_plan were still being evaluated against master-scoped rows from sys.dm_exec_requests before the filter was applied, tripping VIEW SERVER PERFORMANCE STATE errors for DB-scoped logins (D365FO). A CTE or derived table wouldn't guarantee the filter order, so materialise the filtered request rows into #req first and drive the DMFs off that. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../RemoteCollectorService.QuerySnapshots.cs | 121 ++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs index 7ab3765..5148af4 100644 --- a/Lite/Services/RemoteCollectorService.QuerySnapshots.cs +++ b/Lite/Services/RemoteCollectorService.QuerySnapshots.cs @@ -21,7 +21,7 @@ namespace PerformanceMonitorLite.Services; public partial class RemoteCollectorService { private const string QuerySnapshotsBase = """ - + SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SET LOCK_TIMEOUT 1000; @@ -81,17 +81,124 @@ WHERE der.session_id <> @@SPID AND der.session_id >= 50 AND dest.text IS NOT NULL AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0) -{2} ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC OPTION(MAXDOP 1, RECOMPILE); """; private readonly static CompositeFormat QuerySnapshotsBaseFormat = CompositeFormat.Parse(QuerySnapshotsBase); + /* + * On Azure SQL Database with a contained / DB-scoped login (e.g. D365FO), + * the OUTER APPLY to sys.dm_exec_sql_text / sys.dm_exec_text_query_plan + * will be evaluated against master-scoped rows from sys.dm_exec_requests + * before the WHERE predicate applies, tripping error 300 (VIEW SERVER + * PERFORMANCE STATE denied). Materialising the filtered request rows + * into a #temp table first guarantees the DMFs only see handles from + * the current database. See #857. + */ + private const string QuerySnapshotsAzureBase = """ + +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +SET LOCK_TIMEOUT 1000; + +IF OBJECT_ID(N'tempdb..#req') IS NOT NULL DROP TABLE #req; + +SELECT + der.session_id, + der.database_id, + der.sql_handle, + der.plan_handle, + der.statement_start_offset, + der.statement_end_offset, + der.status, + der.blocking_session_id, + der.wait_type, + der.wait_time, + der.wait_resource, + der.cpu_time, + der.total_elapsed_time, + der.reads, + der.writes, + der.logical_reads, + der.granted_query_memory, + der.transaction_isolation_level, + der.dop, + der.parallel_worker_count, + der.percent_complete +INTO #req +FROM sys.dm_exec_requests AS der +WHERE der.session_id <> @@SPID +AND der.session_id >= 50 +AND der.database_id = DB_ID() +AND der.database_id <> ISNULL(DB_ID(N'PerformanceMonitor'), 0); + +SELECT /* PerformanceMonitorLite */ + der.session_id, + database_name = d.name, + elapsed_time_formatted = + CASE + WHEN der.total_elapsed_time < 0 + THEN '00 00:00:00.000' + ELSE RIGHT(REPLICATE('0', 2) + CONVERT(varchar(10), der.total_elapsed_time / 86400000), 2) + + ' ' + RIGHT(CONVERT(varchar(30), DATEADD(second, der.total_elapsed_time / 1000, 0), 120), 9) + + '.' + RIGHT('000' + CONVERT(varchar(3), der.total_elapsed_time % 1000), 3) + END, + query_text = SUBSTRING(dest.text, (der.statement_start_offset / 2) + 1, + ((CASE der.statement_end_offset WHEN -1 THEN DATALENGTH(dest.text) + ELSE der.statement_end_offset END - der.statement_start_offset) / 2) + 1), + query_plan = TRY_CAST(deqp.query_plan AS nvarchar(max)), + {0} + der.status, + der.blocking_session_id, + der.wait_type, + wait_time_ms = CONVERT(bigint, der.wait_time), + der.wait_resource, + cpu_time_ms = CONVERT(bigint, der.cpu_time), + total_elapsed_time_ms = CONVERT(bigint, der.total_elapsed_time), + der.reads, + der.writes, + der.logical_reads, + granted_query_memory_gb = CONVERT(decimal(38, 2), (der.granted_query_memory / 128. / 1024.)), + transaction_isolation_level = + CASE der.transaction_isolation_level + WHEN 0 THEN 'Unspecified' + WHEN 1 THEN 'Read Uncommitted' + WHEN 2 THEN 'Read Committed' + WHEN 3 THEN 'Repeatable Read' + WHEN 4 THEN 'Serializable' + WHEN 5 THEN 'Snapshot' + ELSE '???' + END, + der.dop, + der.parallel_worker_count, + des.login_name, + des.host_name, + des.program_name, + des.open_transaction_count, + der.percent_complete +FROM #req AS der +JOIN sys.dm_exec_sessions AS des + ON des.session_id = der.session_id +OUTER APPLY sys.dm_exec_sql_text(COALESCE(der.sql_handle, der.plan_handle)) AS dest +OUTER APPLY sys.dm_exec_text_query_plan(der.plan_handle, der.statement_start_offset, der.statement_end_offset) AS deqp +LEFT JOIN sys.databases AS d + ON d.database_id = der.database_id +{1} +WHERE dest.text IS NOT NULL +ORDER BY der.cpu_time DESC, der.parallel_worker_count DESC +OPTION(MAXDOP 1, RECOMPILE); + +DROP TABLE #req; +"""; + private readonly static CompositeFormat QuerySnapshotsAzureBaseFormat = CompositeFormat.Parse(QuerySnapshotsAzureBase); + /// /// Builds the query snapshots SQL with or without live query plan support. /// Used by both the collector and the live snapshot button. - /// On Azure SQL Database the result set is scoped to the current database only, - /// because logins without access to master can't resolve cross-database requests (see #857). + /// On Azure SQL Database the request rows are first materialised into a + /// #temp table scoped to the current database, so the downstream OUTER APPLYs + /// to sys.dm_exec_sql_text / sys.dm_exec_text_query_plan only ever see + /// current-DB handles — avoiding the VIEW SERVER PERFORMANCE STATE error + /// that D365FO-style DB-scoped logins hit (see #857). /// internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan, bool isAzureSqlDatabase) { @@ -101,10 +208,8 @@ internal static string BuildQuerySnapshotsQuery(bool supportsLiveQueryPlan, bool var liveQueryPlanApply = supportsLiveQueryPlan ? "OUTER APPLY sys.dm_exec_query_statistics_xml(der.session_id) AS deqs" : ""; - var azureScopePredicate = isAzureSqlDatabase - ? "AND der.database_id = DB_ID()" - : ""; - return string.Format(null, QuerySnapshotsBaseFormat, liveQueryPlanColumn, liveQueryPlanApply, azureScopePredicate); + var template = isAzureSqlDatabase ? QuerySnapshotsAzureBaseFormat : QuerySnapshotsBaseFormat; + return string.Format(null, template, liveQueryPlanColumn, liveQueryPlanApply); } ///