Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ private async Task ShowConnectionDialogAsync()
_selectedDatabase = dialog.ResultDatabase;
_connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase);

/* A new holder for a new connection, made here rather than at the fetch below so a
document opened while the fetch is still in flight already has the right one to
read, and handed to the fetch as a parameter so an offset that lands after ANOTHER
reconnect fills this connection's holder and not the newer one's (E5). The
connection string is captured for the same reason. */
var serverOffset = BeginServerConnection();
var offsetConnectionString = _connectionString;

/* A database metadata fetch still running from before this reconnect was built from
the previous server's connection string. Left alone it can land after
FetchServerMetadataAsync below has replaced _serverMetadata, and write the old
Expand Down Expand Up @@ -81,7 +89,7 @@ whose swallowed failure left a green toolbar over a dead database picker. */
DatabaseBox.IsEnabled = true;

await FetchServerMetadataAsync();
await FetchServerUtcOffset();
await FetchServerUtcOffset(offsetConnectionString, serverOffset);

if (_selectedDatabase != null)
{
Expand Down Expand Up @@ -159,18 +167,36 @@ private async Task FetchServerMetadataAsync()
}
}

private async Task FetchServerUtcOffset()
/// <summary>
/// Starts the offset holder for a new connection. A connect replaces the holder instead of
/// reusing it: documents already open keep the old one, because their data came from the old
/// server, and documents opened from here on get this one. Internal so a test can connect
/// without a server — the connect block calls this and nothing else makes a holder (E5).
/// </summary>
internal ServerUtcOffset BeginServerConnection()
{
if (_connectionString == null) return;
_serverOffset = new ServerUtcOffset();
return _serverOffset;
}

/// <summary>
/// Asks the server it just connected to for its offset from UTC and fills
/// <paramref name="target"/> with it. Takes the connection string and the holder rather than
/// reading the session's fields, so the answer lands on the connection that asked even if the
/// session has reconnected in the meantime. A failed query leaves the holder at zero.
/// </summary>
private static async Task FetchServerUtcOffset(string? connectionString, ServerUtcOffset target)
{
if (connectionString == null) return;
try
{
await using var conn = new SqlConnection(_connectionString);
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
await using var cmd = new SqlCommand(
"SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn);
var offset = await cmd.ExecuteScalarAsync();
if (offset is int mins)
PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins;
target.Minutes = mins;
}
catch { }
}
Expand Down
16 changes: 12 additions & 4 deletions src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,14 +146,23 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini

var databases = DatabaseBox.Items.OfType<string>().ToList();

var grid = new QueryStoreGridControl(_serverConnection!, _credentialService,
database, databases, supportsWaitStats);
var grid = NewQueryStoreGrid(database, databases, supportsWaitStats);
if (initialStartUtc.HasValue && initialEndUtc.HasValue)
grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value);

AddQueryStoreDocument(grid, database);
}

/// <summary>
/// Builds a Query Store grid on the connection the session is on now, which is what gives it
/// that connection's offset holder to keep (E5). Said once because the toolbar's Query Store
/// button and the Overview's drill-down both build one, and a second copy of this line is a
/// second place to forget the holder. Internal so a test can build one without the server
/// check that comes before it in both callers.
/// </summary>
internal QueryStoreGridControl NewQueryStoreGrid(string database, List<string> databases, bool supportsWaitStats) =>
new(_serverConnection!, _credentialService, _serverOffset, database, databases, supportsWaitStats);

/// <summary>
/// Puts a Query Store grid into the strip as a document and shows it.
///
Expand Down Expand Up @@ -231,8 +240,7 @@ family as #448. */
// Build database list from the current DatabaseBox
var databases = DatabaseBox.Items.OfType<string>().ToList();

var grid = new QueryStoreGridControl(_serverConnection!, _credentialService,
_selectedDatabase!, databases, supportsWaitStats);
var grid = NewQueryStoreGrid(_selectedDatabase!, databases, supportsWaitStats);

AddQueryStoreDocument(grid, _selectedDatabase!);
}
Expand Down
2 changes: 1 addition & 1 deletion src/PlanViewer.App/Controls/QuerySessionControl.Views.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ already reports itself. */
private QueryStoreOverviewControl BuildOverviewView()
{
var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
var overview = new QueryStoreOverviewControl(_serverConnection!, _credentialService,
var overview = new QueryStoreOverviewControl(_serverConnection!, _credentialService, _serverOffset,
supportsWaitStats: supportsWaitStats);

overview.DrillDownRequested += async (_, args) =>
Expand Down
10 changes: 10 additions & 0 deletions src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@ public void MarkClean()
private ServerConnection? _serverConnection;
private string? _connectionString;
private string? _selectedDatabase;

/// <summary>
/// The offset from UTC to the server this session is connected to, for Server time display
/// (E5). Every connect replaces it (<see cref="BeginServerConnection"/>), and every document
/// opened on a connection keeps the one that connection had, so a reconnect — or another
/// session on a server in a different time zone — cannot move times that are already on
/// screen. Never null: a session that has not connected holds an empty one, which is zero.
/// </summary>
private ServerUtcOffset _serverOffset = new();

private int _planCounter;
/// <summary>
/// The run in flight, or the last one that was: the source of the query or plan capture the
Expand Down
14 changes: 7 additions & 7 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ private async System.Threading.Tasks.Task FetchFlatPlansAsync(
}

foreach (var plan in plans)
_rows.Add(new QueryStoreRow(plan));
_rows.Add(new QueryStoreRow(plan, _serverOffset));

ApplyFilters();
LoadButton.IsEnabled = true;
Expand Down Expand Up @@ -377,7 +377,7 @@ private List<QueryStoreRow> BuildGroupedRows(QueryStoreGroupedResult grouped)
{
var leafPlan = GroupedRowToPlan(leaf);
leafChildren.Add(new QueryStoreRow(leafPlan, 2,
$"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List<QueryStoreRow>()));
$"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List<QueryStoreRow>(), _serverOffset));
}

// Sort leaf children by metric descending
Expand All @@ -388,7 +388,7 @@ private List<QueryStoreRow> BuildGroupedRows(QueryStoreGroupedResult grouped)
var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
midPlan.QueryText = topLeafForMid.QueryText;
midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren));
midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren, _serverOffset));
}

// Sort mid children by metric descending
Expand All @@ -403,7 +403,7 @@ private List<QueryStoreRow> BuildGroupedRows(QueryStoreGroupedResult grouped)
?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText));
if (topLeafForRoot != null)
aggPlan.QueryText = topLeafForRoot.QueryText;
roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren));
roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren, _serverOffset));
}
}
else // Module
Expand Down Expand Up @@ -431,7 +431,7 @@ private List<QueryStoreRow> BuildGroupedRows(QueryStoreGroupedResult grouped)
{
var leafPlan = GroupedRowToPlan(leaf);
leafChildren.Add(new QueryStoreRow(leafPlan, 2,
$"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List<QueryStoreRow>()));
$"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List<QueryStoreRow>(), _serverOffset));
}

// Sort leaf children by metric descending
Expand All @@ -442,7 +442,7 @@ private List<QueryStoreRow> BuildGroupedRows(QueryStoreGroupedResult grouped)
var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
midPlan.QueryText = topLeafForMid.QueryText;
midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren));
midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren, _serverOffset));
}

// Sort mid children by metric descending
Expand All @@ -457,7 +457,7 @@ private List<QueryStoreRow> BuildGroupedRows(QueryStoreGroupedResult grouped)
?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText));
if (topLeafForRoot != null)
aggPlan.QueryText = topLeafForRoot.QueryText;
roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren));
roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren, _serverOffset));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,14 @@ private void ViewHistory_Click(object? sender, RoutedEventArgs e)

var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);

/* The grid's own holder, not the session's current one: this History reads the grid's
server, and the session may have reconnected somewhere else since the grid opened (E5). */
var control = new QueryStoreHistoryControl(
_connectionString,
row.QueryHash,
row.FullQueryText,
_database,
_serverOffset,
initialMetricTag: metricTag,
slicerStartUtc: _slicerStartUtc,
slicerEndUtc: _slicerEndUtc,
Expand All @@ -120,6 +123,7 @@ private void ViewHistory_Click(object? sender, RoutedEventArgs e)
row.QueryHash,
row.FullQueryText,
_database,
_serverOffset,
initialMetricTag: metricTag,
slicerStartUtc: _slicerStartUtc,
slicerEndUtc: _slicerEndUtc,
Expand Down
2 changes: 2 additions & 0 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ private void TimeDisplay_SelectionChanged(object? sender, SelectionChangedEventA
}
// Refresh slicer labels
TimeRangeSlicer.Redraw();
// Refresh the wait ribbon's labels and tips, which kept the old mode until a resize
WaitStatsProfile.RedrawRibbon();
}

private void UpdateStatusText()
Expand Down
32 changes: 28 additions & 4 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public partial class QueryStoreGridControl : UserControl
{
private readonly ServerConnection _serverConnection;
private readonly ICredentialService _credentialService;
/// <summary>
/// The offset holder of the connection this grid was opened on (E5). Kept for the grid's whole
/// life and handed to everything it builds — rows, slicer, ribbon, History — because they show
/// this server's data, whichever connection the session is on by the time they draw.
/// </summary>
private readonly ServerUtcOffset _serverOffset;
private string _connectionString;
private string _database;
private CancellationTokenSource? _fetchCts;
Expand Down Expand Up @@ -62,10 +68,11 @@ public partial class QueryStoreGridControl : UserControl
public string Database => _database;

public QueryStoreGridControl(ServerConnection serverConnection, ICredentialService credentialService,
string initialDatabase, List<string> databases, bool supportsWaitStats = false)
ServerUtcOffset serverOffset, string initialDatabase, List<string> databases, bool supportsWaitStats = false)
{
_serverConnection = serverConnection;
_credentialService = credentialService;
_serverOffset = serverOffset;
_database = initialDatabase;
_connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase);
_waitStatsSupported = supportsWaitStats;
Expand All @@ -75,6 +82,11 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi

InitializeComponent();

// The slicer and the ribbon are declared in XAML, so they are handed the holder here,
// before any data reaches them.
TimeRangeSlicer.ServerOffset = serverOffset;
WaitStatsProfile.ServerOffset = serverOffset;

// Apply user defaults to UI controls
TopNBox.Value = userSettings.QueryStoreTopLimit;
SelectComboByTag(OrderByBox, userSettings.QueryStoreDefaultMetric);
Expand All @@ -86,6 +98,11 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi
_ => "query-hash"
});

/* The time display mode is one setting for the whole app, so the box opens on the mode in
effect. It used to open on Local whatever the setting or another grid had chosen, and
could say Local beside times shown in Server mode. The tags are the mode names. */
SelectComboByTag(TimeDisplayBox, TimeDisplayHelper.Current.ToString());

// Restore the server-filter panel's expanded state, then subscribe — restoring first
// means the restore itself never triggers a save.
ServerFilterExpander.IsExpanded = userSettings.QueryStoreFilterPanelExpanded;
Expand Down Expand Up @@ -366,19 +383,26 @@ public class QueryStoreRow : INotifyPropertyChanged
private bool _isExpanded;
private int _indentLevel;

// The connection's offset holder (E5): read each time the row formats its time, so Server
// mode shows this row's own server's time whatever else the process is connected to.
private readonly ServerUtcOffset _serverOffset;

/// <summary>Standard constructor for flat (ungrouped) rows.</summary>
public QueryStoreRow(QueryStorePlan plan)
public QueryStoreRow(QueryStorePlan plan, ServerUtcOffset serverOffset)
{
Plan = plan;
_serverOffset = serverOffset;
}

/// <summary>Constructor for grouped parent/intermediate rows (aggregated, no single plan).</summary>
public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List<QueryStoreRow> children)
public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List<QueryStoreRow> children,
ServerUtcOffset serverOffset)
{
Plan = syntheticPlan;
_indentLevel = indentLevel;
GroupLabel = groupLabel;
Children = children;
_serverOffset = serverOffset;
}

public QueryStorePlan Plan { get; }
Expand Down Expand Up @@ -552,7 +576,7 @@ public double WaitMaxGrandTotal
public long TotalMemSort => Plan.TotalMemoryGrantPages;
public double AvgMemSort => Plan.AvgMemoryGrantPages;

public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc);
public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc, _serverOffset.Minutes);

public void NotifyTimeDisplayChanged() => OnPropertyChanged(nameof(LastExecutedLocal));

Expand Down
4 changes: 2 additions & 2 deletions src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ private void UpdateChart()
var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);

var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList();
var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc, _serverOffset.Minutes).ToOADate()).ToArray();
var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray();

var scatter = HistoryChart.Plot.Add.Scatter(xs, ys);
Expand Down Expand Up @@ -158,7 +158,7 @@ private void HighlightDotsOnChart(HashSet<int> rowIndices)
foreach (var group in groups)
{
var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]);
var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc, _serverOffset.Minutes).ToOADate()).ToArray();
var ys = group.Select(r => GetMetricValue(r, tag)).ToArray();

var highlight = HistoryChart.Plot.Add.Scatter(xs, ys);
Expand Down
10 changes: 8 additions & 2 deletions src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
_connectionString, _queryHash, _maxHoursBack, ct);
}

/* The rows come back from Core, which knows nothing about connections. Each one is given
this control's holder here, before anything binds them, so its own time columns read
the server it was fetched from (E5). */
foreach (var row in _historyData)
row.ServerOffset = _serverOffset;

BuildColorMap();
HistoryDataGrid.ItemsSource = _historyData;
ApplyColorIndicators();
Expand All @@ -54,8 +60,8 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count();
var totalExec = _historyData.Sum(r => r.CountExecutions);
var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc));
var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc));
var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc), _serverOffset.Minutes);
var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc), _serverOffset.Minutes);
StatusText.Text = $"{_historyData.Count} intervals, {planCount} plan(s), " +
$"{totalExec:N0} total executions | " +
$"{first:MM/dd HH:mm} to {last:MM/dd HH:mm}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ private void HandleSingleClickSelection(Point clickPoint)
for (int i = 0; i < _historyData.Count; i++)
{
var row = _historyData[i];
var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc);
var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc, _serverOffset.Minutes);
if (row.QueryPlanHash == bestPlanHash &&
Math.Abs((displayTime - clickedTime).TotalMinutes) < 1)
{
Expand All @@ -154,7 +154,7 @@ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinat
for (int i = 0; i < _historyData.Count; i++)
{
var row = _historyData[i];
var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate();
var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc, _serverOffset.Minutes).ToOADate();
var yVal = GetMetricValue(row, tag);

if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2)
Expand Down
Loading
Loading