diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index bc07e18d..dbe405ea 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -1080,31 +1080,132 @@ private void LoadMemoryPressureEventsChart(IEnumerable _memoryPressureEventsHover?.Clear(); TabHelpers.ApplyThemeToChart(MemoryPressureEventsChart); - // Only chart HIGH severity events - var dataList = data?.Where(d => d.Severity.Equals("HIGH", StringComparison.OrdinalIgnoreCase)) - .OrderBy(d => d.SampleTime).ToList() ?? new List(); + // Count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). + var dataList = data? + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .OrderBy(d => d.SampleTime) + .ToList() ?? new List(); + bool hasData = false; + int maxBarCount = 0; + if (dataList.Count > 0) { - // Group by hour and count HIGH events var grouped = dataList .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) .OrderBy(g => g.Key) .ToList(); - if (grouped.Count > 0) + double hourWidth = 1.0 / 24.0; + double barSize = hourWidth * 0.4; + double barOffset = hourWidth * 0.22; + + // Four series: SQL Server medium, SQL Server severe (stacked on top of medium), + // OS medium, OS severe. Stacking uses ValueBase so severe bars sit on top of medium. + var sqlMediumBars = new List(); + var sqlSevereBars = new List(); + var osMediumBars = new List(); + var osSevereBars = new List(); + + var sqlMediumColor = ScottPlot.Color.FromHex("#FFB74D"); // orange 300 + var sqlSevereColor = ScottPlot.Color.FromHex("#E65100"); // orange 900 + var osMediumColor = ScottPlot.Color.FromHex("#E57373"); // red 300 + var osSevereColor = ScottPlot.Color.FromHex("#B71C1C"); // red 900 + + foreach (var g in grouped) + { + int sqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2); + int sqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3); + int osMedium = g.Count(d => d.MemoryIndicatorsSystem == 2); + int osSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3); + double x = g.Key.ToOADate(); + + if (sqlMedium > 0) + { + sqlMediumBars.Add(new ScottPlot.Bar + { + Position = x - barOffset, + ValueBase = 0, + Value = sqlMedium, + Size = barSize, + FillColor = sqlMediumColor, + LineWidth = 0 + }); + } + if (sqlSevere > 0) + { + sqlSevereBars.Add(new ScottPlot.Bar + { + Position = x - barOffset, + ValueBase = sqlMedium, + Value = sqlMedium + sqlSevere, + Size = barSize, + FillColor = sqlSevereColor, + LineWidth = 0 + }); + } + if (osMedium > 0) + { + osMediumBars.Add(new ScottPlot.Bar + { + Position = x + barOffset, + ValueBase = 0, + Value = osMedium, + Size = barSize, + FillColor = osMediumColor, + LineWidth = 0 + }); + } + if (osSevere > 0) + { + osSevereBars.Add(new ScottPlot.Bar + { + Position = x + barOffset, + ValueBase = osMedium, + Value = osMedium + osSevere, + Size = barSize, + FillColor = osSevereColor, + LineWidth = 0 + }); + } + + int sqlTotal = sqlMedium + sqlSevere; + int osTotal = osMedium + osSevere; + if (sqlTotal > maxBarCount) maxBarCount = sqlTotal; + if (osTotal > maxBarCount) maxBarCount = osTotal; + } + + bool anyBars = sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 + || osMediumBars.Count > 0 || osSevereBars.Count > 0; + + if (anyBars) { hasData = true; - var timePoints = grouped.Select(g => g.Key); - double[] highCounts = grouped.Select(g => (double)g.Count()).ToArray(); - var (xs, ys) = TabHelpers.FillTimeSeriesGaps(timePoints, highCounts.Select(c => c)); - var highScatter = MemoryPressureEventsChart.Plot.Add.Scatter(xs, ys); - highScatter.LineWidth = 2; - highScatter.MarkerSize = 5; - highScatter.Color = TabHelpers.ChartColors[3]; - highScatter.LegendText = "High Pressure Events"; - _memoryPressureEventsHover?.Add(highScatter, "High Pressure Events"); + if (sqlMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlMediumBars); + bp.LegendText = "SQL Server (medium)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (medium)"); + } + if (sqlSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlSevereBars); + bp.LegendText = "SQL Server (severe)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (severe)"); + } + if (osMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osMediumBars); + bp.LegendText = "Operating System (medium)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (medium)"); + } + if (osSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osSevereBars); + bp.LegendText = "Operating System (severe)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (severe)"); + } _legendPanels[MemoryPressureEventsChart] = MemoryPressureEventsChart.Plot.ShowLegend(ScottPlot.Edge.Bottom); MemoryPressureEventsChart.Plot.Legend.FontSize = 12; @@ -1114,7 +1215,7 @@ private void LoadMemoryPressureEventsChart(IEnumerable if (!hasData) { double xCenter = xMin + (xMax - xMin) / 2; - var noDataText = MemoryPressureEventsChart.Plot.Add.Text("No data for selected time range", xCenter, 0.5); + var noDataText = MemoryPressureEventsChart.Plot.Add.Text("No memory pressure events in selected time range", xCenter, 0.5); noDataText.LabelFontSize = 14; noDataText.LabelFontColor = ScottPlot.Colors.Gray; noDataText.LabelAlignment = ScottPlot.Alignment.MiddleCenter; @@ -1122,11 +1223,8 @@ private void LoadMemoryPressureEventsChart(IEnumerable MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); - MemoryPressureEventsChart.Plot.YLabel("Event Count"); - // Fixed negative space for legend - MemoryPressureEventsChart.Plot.Axes.AutoScaleY(); - var pressureLimits = MemoryPressureEventsChart.Plot.Axes.GetLimits(); - MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, pressureLimits.Top * 1.05); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + MemoryPressureEventsChart.Plot.Axes.SetLimitsY(0, Math.Max(maxBarCount * 1.1, 5.0)); TabHelpers.LockChartVerticalAxis(MemoryPressureEventsChart); MemoryPressureEventsChart.Refresh(); diff --git a/Dashboard/Helpers/ChartHoverHelper.cs b/Dashboard/Helpers/ChartHoverHelper.cs index b1ec6f11..2ff0ab1a 100644 --- a/Dashboard/Helpers/ChartHoverHelper.cs +++ b/Dashboard/Helpers/ChartHoverHelper.cs @@ -17,6 +17,7 @@ internal sealed class ChartHoverHelper { private readonly ScottPlot.WPF.WpfPlot _chart; private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new(); + private readonly List<(ScottPlot.Plottables.BarPlot BarPlot, string Label)> _barPlots = new(); private readonly Popup _popup; private readonly TextBlock _text; private string _unit; @@ -62,20 +63,28 @@ public void Dispose() _chart.MouseLeave -= OnMouseLeave; _popup.IsOpen = false; _scatters.Clear(); + _barPlots.Clear(); } - public void Clear() => _scatters.Clear(); + public void Clear() + { + _scatters.Clear(); + _barPlots.Clear(); + } public void Add(ScottPlot.Plottables.Scatter scatter, string label) => _scatters.Add((scatter, label)); + public void Add(ScottPlot.Plottables.BarPlot barPlot, string label) => + _barPlots.Add((barPlot, label)); + /// /// Returns the nearest series label and data-point time for the given mouse position, /// or null if no series is close enough. /// public (string Label, DateTime Time)? GetNearestSeries(Point mousePos) { - if (_scatters.Count == 0) return null; + if (_scatters.Count == 0 && _barPlots.Count == 0) return null; try { var dpi = VisualTreeHelper.GetDpi(_chart); @@ -106,6 +115,8 @@ public void Add(ScottPlot.Plottables.Scatter scatter, string label) => } } + FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found); + if (found) return (bestLabel, DateTime.FromOADate(bestPoint.X)); } @@ -113,9 +124,36 @@ public void Add(ScottPlot.Plottables.Scatter scatter, string label) => return null; } + private void FindNearestBar(ScottPlot.Pixel pixel, ref double bestYDistance, + ref ScottPlot.DataPoint bestPoint, ref string bestLabel, ref bool found) + { + foreach (var (barPlot, label) in _barPlots) + { + foreach (var bar in barPlot.Bars) + { + var topPixel = _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position, bar.Value)); + double halfWidthPx = Math.Abs( + _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position + bar.Size / 2, bar.Value)).X + - topPixel.X); + double dx = Math.Abs(topPixel.X - pixel.X); + if (dx > halfWidthPx + 4) continue; + double dy = Math.Abs(topPixel.Y - pixel.Y); + if (dy < bestYDistance) + { + bestYDistance = dy; + // For stacked bars, report the segment height (Value - ValueBase), not the top coordinate + double segmentHeight = bar.Value - bar.ValueBase; + bestPoint = new ScottPlot.DataPoint(new ScottPlot.Coordinates(bar.Position, segmentHeight), 0); + bestLabel = label; + found = true; + } + } + } + } + private void OnMouseMove(object sender, MouseEventArgs e) { - if (_scatters.Count == 0) return; + if (_scatters.Count == 0 && _barPlots.Count == 0) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 30) return; _lastUpdate = now; @@ -158,10 +196,15 @@ pick the series closest in Y (nearest line to cursor). */ } } + FindNearestBar(pixel, ref bestYDistance, ref bestPoint, ref bestLabel, ref found); + if (found) { var time = ServerTimeHelper.ConvertForDisplay(DateTime.FromOADate(bestPoint.X), ServerTimeHelper.CurrentDisplayMode); - _text.Text = $"{bestLabel}\n{bestPoint.Y:N1} {_unit}\n{time:HH:mm:ss}"; + string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y)) + ? bestPoint.Y.ToString("N0") + : bestPoint.Y.ToString("N1"); + _text.Text = $"{bestLabel}\n{valueFormatted} {_unit}\n{time:HH:mm:ss}"; _popup.HorizontalOffset = pos.X + 15; _popup.VerticalOffset = pos.Y + 15; _popup.IsOpen = true; diff --git a/Dashboard/Mcp/McpInstructions.cs b/Dashboard/Mcp/McpInstructions.cs index 65ba0f92..6b628b65 100644 --- a/Dashboard/Mcp/McpInstructions.cs +++ b/Dashboard/Mcp/McpInstructions.cs @@ -217,6 +217,50 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `RESOURCE_SEMAPHORE` | Memory grant pressure | `get_resource_semaphore` | | `LATCH_*` | Internal contention | `get_tempdb_trend` | + ## Interpreting Memory Pressure Events + + `get_memory_pressure_events` returns notifications from the `RING_BUFFER_RESOURCE_MONITOR` ring buffer. The `memory_indicators_process` and `memory_indicators_system` values are SQL Server's Resource Monitor signals. Indicator scale: + + - **0-1**: normal operating state, not actionable + - **2 (medium)**: Resource Monitor has crossed a threshold and is starting to respond — trimming caches, reducing memory grants. Worth investigating if sustained or frequent. + - **3+ (severe)**: aggressive response — buffer pool pages are being evicted, plan cache entries thrown out, workspace memory starved. Always worth investigating. + + The two indicators report different things: + + - `memory_indicators_process` — the SQL Server *process itself* is under memory pressure. Usually workload-induced (large memory grants, plan cache bloat, buffer pool churn). + - `memory_indicators_system` — Windows is signaling low memory *system-wide*. Something on the whole box is consuming memory; SQL Server may or may not be the culprit. + + ### What to check when process pressure (indicator >= 2) fires + + The workload is squeezing SQL Server itself. Follow-up tools: + | Signal to check | Tool | + |-----------------|------| + | Memory grant contention, workspace memory exhaustion | `get_resource_semaphore` | + | Buffer pool composition, memory clerk distribution | `get_memory_clerks` | + | Plan cache bloat (lots of single-use plans) | `get_plan_cache_bloat` | + | Page Life Expectancy, target vs total server memory | `get_memory_stats`, `get_memory_trend` | + | Queries that requested large grants during the window | `get_top_queries_by_cpu`, `get_expensive_queries` | + | `RESOURCE_SEMAPHORE` waits in the same window | `get_wait_stats`, `get_wait_trend` | + + ### What to check when system pressure (indicator >= 2) fires but process does not + + The box is tight on memory, but SQL Server's own process is not the cause. SQL Server feels Windows' low-memory notification but isn't driving it. Typical root causes: other services on the machine (anti-virus, backup agents, monitoring agents, additional SQL instances, SSIS/SSRS, RDP sessions), oversized file system cache, or VM-host memory oversubscription. Follow-up: + + | Signal to check | Tool | + |-----------------|------| + | SQL Server's memory configuration (`max server memory` vs total RAM) | `get_server_properties` | + | Is SQL Server itself actually fine? | `get_memory_stats`, `get_memory_clerks` | + + Most of the diagnosis in this case is *outside* the monitored SQL instance — tell the user to check what else is running on the host. + + ### Patterns + + - **Both process and system firing together** → real capacity problem. Add RAM, tune the workload, or reduce concurrency. + - **Process only** → workload/schema issue, not a hardware problem. Tune queries and indexes. + - **System only** → non-SQL workload on the host; SQL itself is healthy but the tenant mix is tight. + - **Bursty spikes** → correlate the pressure window with `get_running_jobs` (scheduled maintenance, index rebuilds, big reports) and `get_top_queries_by_cpu` for that period. + - **Flat-line sustained** → chronic under-provisioning; memory needs to grow or workload needs to shrink. + ## Tool Relationships - `get_wait_stats` identifies the symptom category (CPU, I/O, locks, parallelism). Other tools find the root cause. diff --git a/Dashboard/Mcp/McpSystemEventTools.cs b/Dashboard/Mcp/McpSystemEventTools.cs index 3aa2c1b5..d4ba0224 100644 --- a/Dashboard/Mcp/McpSystemEventTools.cs +++ b/Dashboard/Mcp/McpSystemEventTools.cs @@ -117,7 +117,17 @@ public static async Task GetTraceAnalysis( } } - [McpServerTool(Name = "get_memory_pressure_events"), Description("Gets memory pressure notifications from the ring buffer. Shows RESOURCE_MEMPHYSICAL_LOW, RESOURCE_MEMVIRTUAL_LOW, and other memory broker notifications with process/system indicators.")] + [McpServerTool(Name = "get_memory_pressure_events"), Description(@"Gets memory pressure notifications from the RING_BUFFER_RESOURCE_MONITOR ring buffer (same source as sp_pressuredetector). Returns RESOURCE_MEMPHYSICAL_LOW, RESOURCE_MEMVIRTUAL_LOW, RESOURCE_MEMPHYSICAL_HIGH, and RESOURCE_MEM_STEADY notifications with indicator values. + +Indicator scale (applies to both memory_indicators_process and memory_indicators_system): + 0-1 = normal, no pressure + 2 = medium pressure (SQL Server's Resource Monitor starts trimming caches and reducing grants) + 3+ = severe pressure (aggressive buffer pool / plan cache eviction) + +memory_indicators_process = SQL Server process itself is under memory pressure (workload-induced). +memory_indicators_system = Windows is signaling low memory system-wide (could be other tenants on the box). + +For actionable interpretation and suggested follow-up tools, see the 'Interpreting Memory Pressure Events' section of the server instructions.")] public static async Task GetMemoryPressureEvents( ServerManager serverManager, DatabaseServiceRegistry registry, diff --git a/Lite.Tests/DuckDbSchemaTests.cs b/Lite.Tests/DuckDbSchemaTests.cs index c0526f64..1377db7a 100644 --- a/Lite.Tests/DuckDbSchemaTests.cs +++ b/Lite.Tests/DuckDbSchemaTests.cs @@ -138,8 +138,8 @@ public void SchemaStatements_MatchTableCount() foreach (var _ in Schema.GetAllTableStatements()) tableCount++; - /* 29 tables from Schema (schema_version is created separately by DuckDbInitializer) */ - Assert.Equal(29, tableCount); + /* 30 tables from Schema (schema_version is created separately by DuckDbInitializer) */ + Assert.Equal(30, tableCount); } [Fact] diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml index e85bc382..749a3b3e 100644 --- a/Lite/Controls/ServerTab.xaml +++ b/Lite/Controls/ServerTab.xaml @@ -1176,6 +1176,13 @@ + + + + + + + diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index 24ed8f90..b15e362b 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -65,6 +65,7 @@ public partial class ServerTab : UserControl private Helpers.ChartHoverHelper? _memoryClerksHover; private Helpers.ChartHoverHelper? _memoryGrantSizingHover; private Helpers.ChartHoverHelper? _memoryGrantActivityHover; + private Helpers.ChartHoverHelper? _memoryPressureEventsHover; private Helpers.ChartHoverHelper? _currentWaitsDurationHover; private Helpers.ChartHoverHelper? _currentWaitsBlockedHover; @@ -202,6 +203,7 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe ApplyTheme(MemoryClerksChart); ApplyTheme(MemoryGrantSizingChart); ApplyTheme(MemoryGrantActivityChart); + ApplyTheme(MemoryPressureEventsChart); ApplyTheme(FileIoReadChart); ApplyTheme(FileIoWriteChart); ApplyTheme(FileIoReadThroughputChart); @@ -240,6 +242,7 @@ public ServerTab(ServerConnection server, DuckDbInitializer duckDb, CredentialSe _memoryClerksHover = new Helpers.ChartHoverHelper(MemoryClerksChart, "MB"); _memoryGrantSizingHover = new Helpers.ChartHoverHelper(MemoryGrantSizingChart, "MB"); _memoryGrantActivityHover = new Helpers.ChartHoverHelper(MemoryGrantActivityChart, ""); + _memoryPressureEventsHover = new Helpers.ChartHoverHelper(MemoryPressureEventsChart, "events"); _currentWaitsDurationHover = new Helpers.ChartHoverHelper(CurrentWaitsDurationChart, "ms"); _currentWaitsBlockedHover = new Helpers.ChartHoverHelper(CurrentWaitsBlockedChart, "sessions"); @@ -918,6 +921,7 @@ private async System.Threading.Tasks.Task RefreshAllTabsAsync(int hoursBack, Dat var queryStoreTask = _dataService.GetQueryStoreTopQueriesAsync(_serverId, hoursBack, 50, fromDate, toDate); var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); + var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); var serverConfigTask = SafeQueryAsync(() => _dataService.GetLatestServerConfigAsync(_serverId)); var databaseConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseConfigAsync(_serverId)); var databaseScopedConfigTask = SafeQueryAsync(() => _dataService.GetLatestDatabaseScopedConfigAsync(_serverId)); @@ -931,7 +935,7 @@ await System.Threading.Tasks.Task.WhenAll( snapshotsTask, cpuTask, memoryTask, memoryTrendTask, queryStatsTask, procStatsTask, fileIoTrendTask, fileIoThroughputTask, tempDbTask, tempDbFileIoTask, deadlockTask, blockedProcessTask, waitTypesTask, memoryClerkTypesTask, perfmonCountersTask, - queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask, + queryStoreTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask, serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask, runningJobsTask, collectionHealthTask, collectionLogTask, dailySummaryTask); @@ -1022,6 +1026,7 @@ await System.Threading.Tasks.Task.WhenAll( UpdateQueryStoreDurationTrendChart(queryStoreDurationTrendTask.Result); UpdateExecutionCountTrendChart(executionCountTrendTask.Result); UpdateMemoryGrantCharts(memoryGrantChartTask.Result); + UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); /* Populate pickers (preserve selections) */ PopulateWaitTypePicker(waitTypesTask.Result); @@ -1367,6 +1372,10 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date var grantChart = await _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); UpdateMemoryGrantCharts(grantChart); break; + case 3: // Memory Pressure Events + var pressureEvents = await _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); + UpdateMemoryPressureEventsChart(pressureEvents, hoursBack, fromDate, toDate); + break; } return; } @@ -1377,12 +1386,14 @@ private async System.Threading.Tasks.Task RefreshMemoryAsync(int hoursBack, Date var memoryClerkTypesTask = _dataService.GetDistinctMemoryClerkTypesAsync(_serverId, hoursBack, fromDate, toDate); var memoryGrantTrendTask = _dataService.GetMemoryGrantTrendAsync(_serverId, hoursBack, fromDate, toDate); var memoryGrantChartTask = _dataService.GetMemoryGrantChartDataAsync(_serverId, hoursBack, fromDate, toDate); + var memoryPressureEventsTask = _dataService.GetMemoryPressureEventsAsync(_serverId, hoursBack, fromDate, toDate); - await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask); + await System.Threading.Tasks.Task.WhenAll(memoryTask, memoryTrendTask, memoryClerkTypesTask, memoryGrantTrendTask, memoryGrantChartTask, memoryPressureEventsTask); UpdateMemorySummary(memoryTask.Result); UpdateMemoryChart(memoryTrendTask.Result, memoryGrantTrendTask.Result); UpdateMemoryGrantCharts(memoryGrantChartTask.Result); + UpdateMemoryPressureEventsChart(memoryPressureEventsTask.Result, hoursBack, fromDate, toDate); PopulateMemoryClerkPicker(memoryClerkTypesTask.Result); await UpdateMemoryClerksChartFromPickerAsync(); } @@ -2033,6 +2044,119 @@ private void UpdateMemoryGrantCharts(List data) MemoryGrantActivityChart.Refresh(); } + /// + /// Stacked bar chart of memory pressure events per hour, split by SQL Server (process) vs + /// Operating System (system) and stacked by severity (medium=indicator 2, severe=indicator >= 3). + /// + private void UpdateMemoryPressureEventsChart(List data, int hoursBack, DateTime? fromDate, DateTime? toDate) + { + ClearChart(MemoryPressureEventsChart); + _memoryPressureEventsHover?.Clear(); + ApplyTheme(MemoryPressureEventsChart); + + DateTime rangeEnd = toDate ?? DateTime.UtcNow.AddMinutes(UtcOffsetMinutes); + DateTime rangeStart = fromDate ?? rangeEnd.AddHours(-hoursBack); + double xMin = rangeStart.ToOADate(); + double xMax = rangeEnd.ToOADate(); + + /* Only count rows where SQL Server reported actual pressure (indicator >= 2 matches sp_pressuredetector). */ + var pressureRows = data + .Where(d => d.MemoryIndicatorsProcess >= 2 || d.MemoryIndicatorsSystem >= 2) + .OrderBy(d => d.SampleTime) + .ToList(); + + bool hasData = false; + int maxBarCount = 0; + + if (pressureRows.Count > 0) + { + var grouped = pressureRows + .GroupBy(d => new DateTime(d.SampleTime.Year, d.SampleTime.Month, d.SampleTime.Day, d.SampleTime.Hour, 0, 0)) + .OrderBy(g => g.Key) + .ToList(); + + double hourWidth = 1.0 / 24.0; + double barSize = hourWidth * 0.4; + double barOffset = hourWidth * 0.22; + + var sqlMediumColor = ScottPlot.Color.FromHex("#FFB74D"); // orange 300 + var sqlSevereColor = ScottPlot.Color.FromHex("#E65100"); // orange 900 + var osMediumColor = ScottPlot.Color.FromHex("#E57373"); // red 300 + var osSevereColor = ScottPlot.Color.FromHex("#B71C1C"); // red 900 + + var sqlMediumBars = new List(); + var sqlSevereBars = new List(); + var osMediumBars = new List(); + var osSevereBars = new List(); + + foreach (var g in grouped) + { + int sqlMedium = g.Count(d => d.MemoryIndicatorsProcess == 2); + int sqlSevere = g.Count(d => d.MemoryIndicatorsProcess >= 3); + int osMedium = g.Count(d => d.MemoryIndicatorsSystem == 2); + int osSevere = g.Count(d => d.MemoryIndicatorsSystem >= 3); + double x = g.Key.AddMinutes(UtcOffsetMinutes).ToOADate(); + + if (sqlMedium > 0) + sqlMediumBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = 0, Value = sqlMedium, Size = barSize, FillColor = sqlMediumColor, LineWidth = 0 }); + if (sqlSevere > 0) + sqlSevereBars.Add(new ScottPlot.Bar { Position = x - barOffset, ValueBase = sqlMedium, Value = sqlMedium + sqlSevere, Size = barSize, FillColor = sqlSevereColor, LineWidth = 0 }); + if (osMedium > 0) + osMediumBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = 0, Value = osMedium, Size = barSize, FillColor = osMediumColor, LineWidth = 0 }); + if (osSevere > 0) + osSevereBars.Add(new ScottPlot.Bar { Position = x + barOffset, ValueBase = osMedium, Value = osMedium + osSevere, Size = barSize, FillColor = osSevereColor, LineWidth = 0 }); + + int sqlTotal = sqlMedium + sqlSevere; + int osTotal = osMedium + osSevere; + if (sqlTotal > maxBarCount) maxBarCount = sqlTotal; + if (osTotal > maxBarCount) maxBarCount = osTotal; + } + + if (sqlMediumBars.Count > 0 || sqlSevereBars.Count > 0 || osMediumBars.Count > 0 || osSevereBars.Count > 0) + { + hasData = true; + + if (sqlMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlMediumBars); + bp.LegendText = "SQL Server (medium)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (medium)"); + } + if (sqlSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(sqlSevereBars); + bp.LegendText = "SQL Server (severe)"; + _memoryPressureEventsHover?.Add(bp, "SQL Server (severe)"); + } + if (osMediumBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osMediumBars); + bp.LegendText = "Operating System (medium)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (medium)"); + } + if (osSevereBars.Count > 0) + { + var bp = MemoryPressureEventsChart.Plot.Add.Bars(osSevereBars); + bp.LegendText = "Operating System (severe)"; + _memoryPressureEventsHover?.Add(bp, "Operating System (severe)"); + } + } + } + + MemoryPressureEventsChart.Plot.Axes.DateTimeTicksBottomDateChange(); + MemoryPressureEventsChart.Plot.Axes.SetLimitsX(xMin, xMax); + ReapplyAxisColors(MemoryPressureEventsChart); + MemoryPressureEventsChart.Plot.YLabel("Pressure Events per Hour"); + SetChartYLimitsWithLegendPadding(MemoryPressureEventsChart, 0, Math.Max(maxBarCount, 5)); + + if (hasData) + { + ShowChartLegend(MemoryPressureEventsChart); + } + + MemoryPressureEventsChart.Refresh(); + } + private void UpdateTempDbChart(List data) { ClearChart(TempDbChart); @@ -5367,6 +5491,7 @@ public void DisposeChartHelpers() _memoryClerksHover?.Dispose(); _memoryGrantSizingHover?.Dispose(); _memoryGrantActivityHover?.Dispose(); + _memoryPressureEventsHover?.Dispose(); _currentWaitsDurationHover?.Dispose(); _currentWaitsBlockedHover?.Dispose(); } diff --git a/Lite/Database/DuckDbInitializer.cs b/Lite/Database/DuckDbInitializer.cs index c7295783..e767c6d4 100644 --- a/Lite/Database/DuckDbInitializer.cs +++ b/Lite/Database/DuckDbInitializer.cs @@ -97,7 +97,7 @@ public void Dispose() /// /// Current schema version. Increment this when schema changes require table rebuilds. /// - internal const int CurrentSchemaVersion = 24; + internal const int CurrentSchemaVersion = 25; private readonly string _archivePath; @@ -114,8 +114,8 @@ public DuckDbInitializer(string databasePath, ILogger? logger [ "wait_stats", "query_stats", "procedure_stats", "query_store_stats", "query_snapshots", "cpu_utilization_stats", "file_io_stats", "memory_stats", - "memory_clerks", "tempdb_stats", "perfmon_stats", "deadlocks", - "blocked_process_reports", "memory_grant_stats", "waiting_tasks", + "memory_clerks", "memory_pressure_events", "tempdb_stats", "perfmon_stats", + "deadlocks", "blocked_process_reports", "memory_grant_stats", "waiting_tasks", "running_jobs", "database_size_stats", "server_properties", "session_stats", "server_config", "database_config", "database_scoped_config", "trace_flags", "config_alert_log", @@ -639,6 +639,13 @@ New tables only — no existing table changes needed. Tables created by throw; } } + + if (fromVersion < 25) + { + /* v25: Added memory_pressure_events table for RING_BUFFER_RESOURCE_MONITOR notifications. + New table only — created by GetAllTableStatements(). */ + _logger?.LogInformation("Running migration to v25: adding memory_pressure_events table"); + } } /// @@ -651,9 +658,9 @@ private async Task FixServerIdsAsync(DuckDBConnection connection) var tablesWithServerId = new[] { "servers", "collection_log", "wait_stats", "query_stats", "cpu_utilization_stats", - "file_io_stats", "memory_stats", "memory_clerks", "deadlocks", - "procedure_stats", "query_store_stats", "query_snapshots", "tempdb_stats", - "perfmon_stats", "server_config", "database_config", + "file_io_stats", "memory_stats", "memory_clerks", "memory_pressure_events", + "deadlocks", "procedure_stats", "query_store_stats", "query_snapshots", + "tempdb_stats", "perfmon_stats", "server_config", "database_config", "blocked_process_reports", "memory_grant_stats", "waiting_tasks" }; diff --git a/Lite/Database/Schema.cs b/Lite/Database/Schema.cs index be4b3561..e8d8b7b8 100644 --- a/Lite/Database/Schema.cs +++ b/Lite/Database/Schema.cs @@ -190,6 +190,18 @@ CREATE TABLE IF NOT EXISTS memory_clerks ( memory_mb DECIMAL(18,2) )"; + public const string CreateMemoryPressureEventsTable = @" +CREATE TABLE IF NOT EXISTS memory_pressure_events ( + collection_id BIGINT PRIMARY KEY, + collection_time TIMESTAMP NOT NULL, + server_id INTEGER NOT NULL, + server_name VARCHAR NOT NULL, + sample_time TIMESTAMP NOT NULL, + memory_notification VARCHAR NOT NULL, + memory_indicators_process INTEGER NOT NULL, + memory_indicators_system INTEGER NOT NULL +)"; + public const string CreateDeadlocksTable = @" CREATE TABLE IF NOT EXISTS deadlocks ( deadlock_id BIGINT PRIMARY KEY, @@ -519,6 +531,9 @@ is_optimized_locking_on BOOLEAN public const string CreateMemoryIndex = @" CREATE INDEX IF NOT EXISTS idx_memory_time ON memory_stats(server_id, collection_time)"; + public const string CreateMemoryPressureEventsIndex = @" +CREATE INDEX IF NOT EXISTS idx_memory_pressure_events_time ON memory_pressure_events(server_id, sample_time)"; + public const string CreateTempdbIndex = @" CREATE INDEX IF NOT EXISTS idx_tempdb_time ON tempdb_stats(server_id, collection_time)"; @@ -726,6 +741,7 @@ public static IEnumerable GetAllTableStatements() yield return CreateFileIoStatsTable; yield return CreateMemoryStatsTable; yield return CreateMemoryClerksTable; + yield return CreateMemoryPressureEventsTable; yield return CreateDeadlocksTable; yield return CreateProcedureStatsTable; yield return CreateQueryStoreStatsTable; @@ -769,6 +785,7 @@ public static IEnumerable GetAllIndexStatements() yield return CreateWaitingTasksIndex; yield return CreateBlockedProcessReportsIndex; yield return CreateMemoryClerksIndex; + yield return CreateMemoryPressureEventsIndex; yield return CreateDatabaseScopedConfigIndex; yield return CreateTraceFlagsIndex; yield return CreateRunningJobsIndex; diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs index 794b293b..28241eb4 100644 --- a/Lite/Helpers/ChartHoverHelper.cs +++ b/Lite/Helpers/ChartHoverHelper.cs @@ -17,6 +17,7 @@ internal sealed class ChartHoverHelper { private readonly ScottPlot.WPF.WpfPlot _chart; private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new(); + private readonly List<(ScottPlot.Plottables.BarPlot BarPlot, string Label)> _barPlots = new(); private readonly Popup _popup; private readonly TextBlock _text; private string _unit; @@ -62,20 +63,28 @@ public void Dispose() _chart.MouseLeave -= OnMouseLeave; _popup.IsOpen = false; _scatters.Clear(); + _barPlots.Clear(); } - public void Clear() => _scatters.Clear(); + public void Clear() + { + _scatters.Clear(); + _barPlots.Clear(); + } public void Add(ScottPlot.Plottables.Scatter scatter, string label) => _scatters.Add((scatter, label)); + public void Add(ScottPlot.Plottables.BarPlot barPlot, string label) => + _barPlots.Add((barPlot, label)); + /// /// Returns the nearest series label and data-point time for the given mouse position, /// or null if no series is close enough. /// public (string Label, DateTime Time)? GetNearestSeries(Point mousePos) { - if (_scatters.Count == 0) return null; + if (_scatters.Count == 0 && _barPlots.Count == 0) return null; var dpi = VisualTreeHelper.GetDpi(_chart); var pixel = new ScottPlot.Pixel( (float)(mousePos.X * dpi.DpiScaleX), @@ -103,14 +112,42 @@ public void Add(ScottPlot.Plottables.Scatter scatter, string label) => } } + FindNearestBar(pixel, ref bestDistance, ref bestPoint, ref bestLabel); + if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius return (bestLabel, DateTime.FromOADate(bestPoint.X)); return null; } + private void FindNearestBar(ScottPlot.Pixel pixel, ref double bestDistance, + ref ScottPlot.DataPoint bestPoint, ref string bestLabel) + { + foreach (var (barPlot, label) in _barPlots) + { + foreach (var bar in barPlot.Bars) + { + var topPixel = _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position, bar.Value)); + double halfWidthPx = Math.Abs( + _chart.Plot.GetPixel(new ScottPlot.Coordinates(bar.Position + bar.Size / 2, bar.Value)).X + - topPixel.X); + double dx = Math.Abs(topPixel.X - pixel.X); + if (dx > halfWidthPx + 4) continue; + double dy = Math.Abs(topPixel.Y - pixel.Y); + double dist = dx * dx + dy * dy; + if (dist < bestDistance) + { + bestDistance = dist; + double segmentHeight = bar.Value - bar.ValueBase; + bestPoint = new ScottPlot.DataPoint(new ScottPlot.Coordinates(bar.Position, segmentHeight), 0); + bestLabel = label; + } + } + } + } + private void OnMouseMove(object sender, MouseEventArgs e) { - if (_scatters.Count == 0) return; + if (_scatters.Count == 0 && _barPlots.Count == 0) return; var now = DateTime.UtcNow; if ((now - _lastUpdate).TotalMilliseconds < 50) return; _lastUpdate = now; @@ -145,10 +182,15 @@ private void OnMouseMove(object sender, MouseEventArgs e) } } + FindNearestBar(pixel, ref bestDistance, ref bestPoint, ref bestLabel); + if (bestPoint.IsReal && bestDistance < 2500) // ~50px radius { var time = ServerTimeHelper.ConvertForDisplay(DateTime.FromOADate(bestPoint.X), ServerTimeHelper.CurrentDisplayMode); - _text.Text = $"{bestLabel}\n{bestPoint.Y:N1} {_unit}\n{time:HH:mm:ss}"; + string valueFormatted = (bestPoint.Y == Math.Floor(bestPoint.Y)) + ? bestPoint.Y.ToString("N0") + : bestPoint.Y.ToString("N1"); + _text.Text = $"{bestLabel}\n{valueFormatted} {_unit}\n{time:HH:mm:ss}"; _popup.HorizontalOffset = pos.X + 15; _popup.VerticalOffset = pos.Y + 15; _popup.IsOpen = true; diff --git a/Lite/Mcp/McpInstructions.cs b/Lite/Mcp/McpInstructions.cs index c063783b..766fcc9c 100644 --- a/Lite/Mcp/McpInstructions.cs +++ b/Lite/Mcp/McpInstructions.cs @@ -78,6 +78,7 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo | `get_memory_trend` | Memory usage over time | `server_name`, `hours_back` | | `get_memory_clerks` | Top memory consumers by clerk type | `server_name` | | `get_memory_grants` | Active/recent memory grants (detect grant pressure) | `server_name`, `hours_back` (default 1), `limit` | + | `get_memory_pressure_events` | Ring buffer memory pressure notifications (sp_pressuredetector source) | `server_name`, `hours_back` | ### I/O Tools | Tool | Purpose | Key Parameters | @@ -196,6 +197,49 @@ You are connected to a SQL Server performance monitoring tool via Performance Mo **Use `get_blocking` first** for a quick overview. **Use `get_blocked_process_reports`** when you need detailed analysis of prolonged blocking events. + ## Interpreting Memory Pressure Events + + `get_memory_pressure_events` returns notifications from the `RING_BUFFER_RESOURCE_MONITOR` ring buffer. The `memory_indicators_process` and `memory_indicators_system` values are SQL Server's Resource Monitor signals. Indicator scale: + + - **0-1**: normal operating state, not actionable + - **2 (medium)**: Resource Monitor has crossed a threshold and is starting to respond — trimming caches, reducing memory grants. Worth investigating if sustained or frequent. + - **3+ (severe)**: aggressive response — buffer pool pages are being evicted, plan cache entries thrown out, workspace memory starved. Always worth investigating. + + The two indicators report different things: + + - `memory_indicators_process` — the SQL Server *process itself* is under memory pressure. Usually workload-induced (large memory grants, plan cache bloat, buffer pool churn). + - `memory_indicators_system` — Windows is signaling low memory *system-wide*. Something on the whole box is consuming memory; SQL Server may or may not be the culprit. + + ### What to check when process pressure (indicator >= 2) fires + + The workload is squeezing SQL Server itself. Follow-up tools: + | Signal to check | Tool | + |-----------------|------| + | Memory grant contention, workspace memory pressure | `get_memory_grants` | + | Buffer pool composition, memory clerk distribution | `get_memory_clerks` | + | Page Life Expectancy, target vs total server memory | `get_memory_stats`, `get_memory_trend` | + | Queries that requested large grants during the window | `get_top_queries_by_cpu` | + | `RESOURCE_SEMAPHORE` waits in the same window | `get_wait_stats`, `get_wait_trend` | + + ### What to check when system pressure (indicator >= 2) fires but process does not + + The box is tight on memory, but SQL Server's own process is not the cause. SQL Server feels Windows' low-memory notification but isn't driving it. Typical root causes: other services on the machine (anti-virus, backup agents, monitoring agents, additional SQL instances, SSIS/SSRS, RDP sessions), oversized file system cache, or VM-host memory oversubscription. Follow-up: + + | Signal to check | Tool | + |-----------------|------| + | SQL Server's memory configuration (`max server memory` vs total RAM) | `get_server_properties` | + | Is SQL Server itself actually fine? | `get_memory_stats`, `get_memory_clerks` | + + Most of the diagnosis in this case is *outside* the monitored SQL instance — tell the user to check what else is running on the host. + + ### Patterns + + - **Both process and system firing together** → real capacity problem. Add RAM, tune the workload, or reduce concurrency. + - **Process only** → workload/schema issue, not a hardware problem. Tune queries and indexes. + - **System only** → non-SQL workload on the host; SQL itself is healthy but the tenant mix is tight. + - **Bursty spikes** → correlate the pressure window with `get_running_jobs` (scheduled maintenance, index rebuilds, big reports) and `get_top_queries_by_cpu` for that period. + - **Flat-line sustained** → chronic under-provisioning; memory needs to grow or workload needs to shrink. + ## Tool Relationships - `get_wait_stats` identifies the symptom category (CPU, I/O, locks, parallelism). Other tools find the root cause. diff --git a/Lite/Mcp/McpMemoryTools.cs b/Lite/Mcp/McpMemoryTools.cs index 920797a8..5de9eccd 100644 --- a/Lite/Mcp/McpMemoryTools.cs +++ b/Lite/Mcp/McpMemoryTools.cs @@ -124,6 +124,59 @@ public static async Task GetMemoryClerks( } } + [McpServerTool(Name = "get_memory_pressure_events"), Description(@"Gets memory pressure notifications from the RING_BUFFER_RESOURCE_MONITOR ring buffer (same source as sp_pressuredetector). Returns RESOURCE_MEMPHYSICAL_LOW, RESOURCE_MEMVIRTUAL_LOW, RESOURCE_MEMPHYSICAL_HIGH, and RESOURCE_MEM_STEADY notifications with indicator values. + +Indicator scale (applies to both memory_indicators_process and memory_indicators_system): + 0-1 = normal, no pressure + 2 = medium pressure (SQL Server's Resource Monitor starts trimming caches and reducing grants) + 3+ = severe pressure (aggressive buffer pool / plan cache eviction) + +memory_indicators_process = SQL Server process itself is under memory pressure (workload-induced). +memory_indicators_system = Windows is signaling low memory system-wide (could be other tenants on the box). + +Not available on Azure SQL DB (ring buffer not exposed). For actionable interpretation and suggested follow-up tools, see the 'Interpreting Memory Pressure Events' section of the server instructions.")] + public static async Task GetMemoryPressureEvents( + LocalDataService dataService, + ServerManager serverManager, + [Description("Server name or display name.")] string? server_name = null, + [Description("Hours of history. Default 24.")] int hours_back = 24) + { + var resolved = ServerResolver.Resolve(serverManager, server_name); + if (resolved == null) + { + return $"Could not resolve server. Available servers:\n{ServerResolver.ListAvailableServers(serverManager)}"; + } + + try + { + var hoursError = McpHelpers.ValidateHoursBack(hours_back); + if (hoursError != null) return hoursError; + + var rows = await dataService.GetMemoryPressureEventsAsync(resolved.Value.ServerId, hours_back); + if (rows.Count == 0) + { + return "No memory pressure events found in the requested time range."; + } + + return JsonSerializer.Serialize(new + { + server = resolved.Value.ServerName, + hours_back, + events = rows.Select(r => new + { + sample_time = r.SampleTime.ToString("o"), + memory_notification = r.MemoryNotification, + memory_indicators_process = r.MemoryIndicatorsProcess, + memory_indicators_system = r.MemoryIndicatorsSystem + }) + }, McpHelpers.JsonOptions); + } + catch (Exception ex) + { + return McpHelpers.FormatError("get_memory_pressure_events", ex); + } + } + [McpServerTool(Name = "get_memory_grants"), Description("Gets resource semaphore statistics showing granted vs available workspace memory per resource pool, waiter counts, and timeout/forced grant deltas. High waiter counts or rising timeout deltas indicate memory grant pressure affecting query performance.")] public static async Task GetMemoryGrants( LocalDataService dataService, diff --git a/Lite/Services/ArchiveService.cs b/Lite/Services/ArchiveService.cs index 35d9c7e1..d7789e5d 100644 --- a/Lite/Services/ArchiveService.cs +++ b/Lite/Services/ArchiveService.cs @@ -56,6 +56,7 @@ internal static readonly (string Table, string TimeColumn)[] ArchivableTables = ("file_io_stats", "collection_time"), ("memory_stats", "collection_time"), ("memory_clerks", "collection_time"), + ("memory_pressure_events", "collection_time"), ("tempdb_stats", "collection_time"), ("perfmon_stats", "collection_time"), ("deadlocks", "collection_time"), diff --git a/Lite/Services/LocalDataService.Memory.cs b/Lite/Services/LocalDataService.Memory.cs index 3b581927..38b9e94a 100644 --- a/Lite/Services/LocalDataService.Memory.cs +++ b/Lite/Services/LocalDataService.Memory.cs @@ -181,6 +181,48 @@ FROM v_memory_clerks return items; } + /// + /// Gets memory pressure events (from RING_BUFFER_RESOURCE_MONITOR) for charting. + /// + public async Task> GetMemoryPressureEventsAsync(int serverId, int hoursBack = 24, DateTime? fromDate = null, DateTime? toDate = null) + { + using var connection = await OpenConnectionAsync(); + using var command = connection.CreateCommand(); + + var (startTime, endTime) = GetTimeRange(hoursBack, fromDate, toDate); + + command.CommandText = @" +SELECT + sample_time, + memory_notification, + memory_indicators_process, + memory_indicators_system +FROM v_memory_pressure_events +WHERE server_id = $1 +AND sample_time >= $2 +AND sample_time <= $3 +ORDER BY sample_time"; + + command.Parameters.Add(new DuckDBParameter { Value = serverId }); + command.Parameters.Add(new DuckDBParameter { Value = startTime }); + command.Parameters.Add(new DuckDBParameter { Value = endTime }); + + var items = new List(); + using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + items.Add(new MemoryPressureEventRow + { + SampleTime = reader.GetDateTime(0), + MemoryNotification = reader.IsDBNull(1) ? "" : reader.GetString(1), + MemoryIndicatorsProcess = reader.IsDBNull(2) ? 0 : reader.GetInt32(2), + MemoryIndicatorsSystem = reader.IsDBNull(3) ? 0 : reader.GetInt32(3) + }); + } + + return items; + } + /// /// Gets the latest memory clerk breakdown. /// @@ -252,3 +294,11 @@ public class MemoryClerkTrendPoint public string ClerkType { get; set; } = ""; public double MemoryMb { get; set; } } + +public class MemoryPressureEventRow +{ + public DateTime SampleTime { get; set; } + public string MemoryNotification { get; set; } = ""; + public int MemoryIndicatorsProcess { get; set; } + public int MemoryIndicatorsSystem { get; set; } +} diff --git a/Lite/Services/RemoteCollectorService.Memory.cs b/Lite/Services/RemoteCollectorService.Memory.cs index 4af53786..e90339e7 100644 --- a/Lite/Services/RemoteCollectorService.Memory.cs +++ b/Lite/Services/RemoteCollectorService.Memory.cs @@ -266,4 +266,108 @@ ORDER BY _logger?.LogDebug("Collected {RowCount} memory clerks for server '{Server}'", rowsCollected, server.DisplayName); return rowsCollected; } + + /// + /// Collects memory pressure notifications from RING_BUFFER_RESOURCE_MONITOR. + /// Same source as sp_pressuredetector — reports IndicatorsProcess/IndicatorsSystem + /// (0-1 normal, 2 medium pressure, 3+ severe) alongside the notification type. + /// Azure SQL DB does not expose sys.dm_os_ring_buffers, so this collector returns 0 there. + /// + private async Task CollectMemoryPressureEventsAsync(ServerConnection server, CancellationToken cancellationToken) + { + var serverStatus = _serverManager.GetConnectionStatus(server.Id); + bool isAzureSqlDb = serverStatus.SqlEngineEdition == 5; + + _lastSqlMs = 0; + _lastDuckDbMs = 0; + + if (isAzureSqlDb) + { + /* Ring buffer is not exposed on Azure SQL DB */ + return 0; + } + + const string query = @" +SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; + +DECLARE + @ms_ticks bigint, + @now datetime2(7) = SYSDATETIME(); + +SELECT @ms_ticks = dosi.ms_ticks FROM sys.dm_os_sys_info AS dosi; + +SELECT + sample_time = DATEADD(SECOND, -((@ms_ticks - t.timestamp) / 1000), @now), + memory_notification = t.record.value('(/Record/ResourceMonitor/Notification)[1]', 'nvarchar(100)'), + memory_indicators_process = t.record.value('(/Record/ResourceMonitor/IndicatorsProcess)[1]', 'integer'), + memory_indicators_system = t.record.value('(/Record/ResourceMonitor/IndicatorsSystem)[1]', 'integer') +FROM +( + SELECT + dorb.timestamp, + record = CONVERT(xml, dorb.record) + FROM sys.dm_os_ring_buffers AS dorb + WHERE dorb.ring_buffer_type = N'RING_BUFFER_RESOURCE_MONITOR' +) AS t +ORDER BY t.timestamp +OPTION(RECOMPILE);"; + + var serverId = GetServerId(server); + var collectionTime = DateTime.UtcNow; + var rowsCollected = 0; + + /* Client-side dedup: computed sample_time cannot be filtered server-side + (it's derived from ms_ticks on each read). Fetch all and skip rows we already have. */ + var lastSampleTime = await GetLastCollectedTimeAsync( + serverId, "memory_pressure_events", "sample_time", cancellationToken); + + var sqlSw = Stopwatch.StartNew(); + using var sqlConnection = await CreateConnectionAsync(server, cancellationToken); + using var command = new SqlCommand(query, sqlConnection); + command.CommandTimeout = CommandTimeoutSeconds; + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + sqlSw.Stop(); + _lastSqlMs = sqlSw.ElapsedMilliseconds; + + var duckSw = Stopwatch.StartNew(); + + using (var duckConnection = _duckDb.CreateConnection()) + { + await duckConnection.OpenAsync(cancellationToken); + + using (var appender = duckConnection.CreateAppender("memory_pressure_events")) + { + while (await reader.ReadAsync(cancellationToken)) + { + var sampleTime = reader.IsDBNull(0) ? DateTime.MinValue : reader.GetDateTime(0); + if (lastSampleTime.HasValue && sampleTime <= lastSampleTime.Value) + continue; + + var notification = reader.IsDBNull(1) ? "" : reader.GetString(1); + var indicatorsProcess = reader.IsDBNull(2) ? 0 : reader.GetInt32(2); + var indicatorsSystem = reader.IsDBNull(3) ? 0 : reader.GetInt32(3); + + var row = appender.CreateRow(); + row.AppendValue(GenerateCollectionId()) + .AppendValue(collectionTime) + .AppendValue(serverId) + .AppendValue(GetServerNameForStorage(server)) + .AppendValue(sampleTime) + .AppendValue(notification) + .AppendValue(indicatorsProcess) + .AppendValue(indicatorsSystem) + .EndRow(); + + rowsCollected++; + } + } + } + + duckSw.Stop(); + _lastDuckDbMs = duckSw.ElapsedMilliseconds; + + _logger?.LogDebug("Collected {RowCount} memory pressure events for server '{Server}'", rowsCollected, server.DisplayName); + return rowsCollected; + } } diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index c3564a71..541c4e53 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -389,6 +389,7 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam "cpu_utilization" => await CollectCpuUtilizationAsync(server, cancellationToken), "memory_stats" => await CollectMemoryStatsAsync(server, cancellationToken), "memory_clerks" => await CollectMemoryClerksAsync(server, cancellationToken), + "memory_pressure_events" => await CollectMemoryPressureEventsAsync(server, cancellationToken), "file_io_stats" => await CollectFileIoStatsAsync(server, cancellationToken), "query_stats" => await CollectQueryStatsAsync(server, cancellationToken), "procedure_stats" => await CollectProcedureStatsAsync(server, cancellationToken), diff --git a/Lite/Services/ScheduleManager.cs b/Lite/Services/ScheduleManager.cs index d6a2bbd9..223c032c 100644 --- a/Lite/Services/ScheduleManager.cs +++ b/Lite/Services/ScheduleManager.cs @@ -38,6 +38,7 @@ public class ScheduleManager ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, ["query_store"] = 2, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 2, + ["memory_pressure_events"] = 5, ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, ["blocked_process_report"] = 1, ["running_jobs"] = 2 @@ -47,6 +48,7 @@ public class ScheduleManager ["wait_stats"] = 1, ["query_stats"] = 1, ["procedure_stats"] = 1, ["query_store"] = 5, ["query_snapshots"] = 1, ["cpu_utilization"] = 1, ["file_io_stats"] = 1, ["memory_stats"] = 1, ["memory_clerks"] = 5, + ["memory_pressure_events"] = 5, ["tempdb_stats"] = 1, ["perfmon_stats"] = 1, ["deadlocks"] = 1, ["memory_grant_stats"] = 1, ["waiting_tasks"] = 1, ["blocked_process_report"] = 1, ["running_jobs"] = 5 @@ -56,6 +58,7 @@ public class ScheduleManager ["wait_stats"] = 5, ["query_stats"] = 10, ["procedure_stats"] = 10, ["query_store"] = 30, ["query_snapshots"] = 5, ["cpu_utilization"] = 5, ["file_io_stats"] = 10, ["memory_stats"] = 10, ["memory_clerks"] = 30, + ["memory_pressure_events"] = 15, ["tempdb_stats"] = 5, ["perfmon_stats"] = 5, ["deadlocks"] = 5, ["memory_grant_stats"] = 5, ["waiting_tasks"] = 5, ["blocked_process_report"] = 5, ["running_jobs"] = 30 @@ -739,6 +742,7 @@ private static List GetDefaultSchedules() new() { Name = "file_io_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "File I/O statistics from sys.dm_io_virtual_file_stats" }, new() { Name = "memory_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Memory statistics from sys.dm_os_sys_memory and performance counters" }, new() { Name = "memory_clerks", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Memory clerk allocations from sys.dm_os_memory_clerks" }, + new() { Name = "memory_pressure_events", Enabled = true, FrequencyMinutes = 5, RetentionDays = 30, Description = "Memory pressure notifications from RING_BUFFER_RESOURCE_MONITOR" }, new() { Name = "tempdb_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "TempDB space usage from sys.dm_db_file_space_usage" }, new() { Name = "perfmon_stats", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Key performance counters from sys.dm_os_performance_counters" }, new() { Name = "deadlocks", Enabled = true, FrequencyMinutes = 1, RetentionDays = 30, Description = "Deadlocks from system_health extended event session" },