From fa97cdde7b61836fa0e13e39817a6af93e7eddb8 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:43:08 -0400 Subject: [PATCH] Nest Early abort under Optimization and list CE model above it (#613) The early abort reason is part of the optimization result, so the App's Runtime Summary now shows it on the row under Optimization with its label indented. CE model moves up above Optimization, so Optimization and its reason end the list, as in the issue's mockup. The web Runtime card and the HTML export follow the same #215 E11 order, so CE Model moves above Optimization there too. They have no Early abort row, so there is nothing to nest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019tS6P95Dtzs4aeMpb1xqzX --- .../PlanViewerControl.RuntimeSummary.cs | 20 ++-- src/PlanViewer.Core/Output/HtmlExporter.cs | 9 +- src/PlanViewer.Web/Pages/InsightsPanel.razor | 15 +-- .../HtmlExporterTests.cs | 18 ++++ .../RuntimeSummaryEarlyAbortTests.cs | 94 +++++++++++++++++++ 5 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs index cfa38123..bbb482dd 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs @@ -30,7 +30,8 @@ private void ShowRuntimeSummary(PlanStatement statement) }; int rowIndex = 0; - void AddRow(string label, string value, string? brushKey = null) + // nested: the row is a detail of the row above it, so its label is indented under that row's. + void AddRow(string label, string value, string? brushKey = null, bool nested = false) { grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); @@ -40,7 +41,7 @@ void AddRow(string label, string value, string? brushKey = null) FontSize = 11, Foreground = labelBrush, HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 8, 1) + Margin = new Thickness(nested ? 12 : 0, 1, 8, 1) }; Grid.SetRow(labelText, rowIndex); Grid.SetColumn(labelText, 0); @@ -84,7 +85,8 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill) var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); - // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → CE Model → Optimization → Cost. + // #613 moved CE Model above Optimization, so Optimization and its nested early abort reason end the list. // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. if (statement.QueryTimeStats != null) @@ -188,13 +190,15 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill) } } - // Optimization + CE model - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + // CE model, then Optimization. #613: the early abort reason is part of the optimization + // result, not a fact of its own, so it sits under the Optimization row. if (statement.CardinalityEstimationModelVersion > 0) AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + var hasOptimizationRow = !string.IsNullOrEmpty(statement.StatementOptmLevel); + if (hasOptimizationRow) + AddRow("Optimization", statement.StatementOptmLevel!); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason, nested: hasOptimizationRow); if (grid.Children.Count > 0) { diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4aad7cc6..137ba3bc 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -308,8 +308,9 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) sb.AppendLine("
"); // Order per Joe (#215 E11): Elapsed → CPU:Elapsed → DOP → CPU → Compile → - // Memory → Used → Optimization → CE Model → Cost. Puts the important - // measurements on top and groups related metrics together. + // Memory → Used → CE Model → Optimization → Cost. Puts the important + // measurements on top and groups related metrics together. #613 moved CE Model + // above Optimization, the same order as the App's Runtime Summary. if (stmt.QueryTime != null) { WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms"); @@ -343,10 +344,10 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) if (stmt.MemoryGrant.SerialRequiredKB > 0 && stmt.MemoryGrant.SerialRequiredKB != stmt.MemoryGrant.DesiredKB) WriteRow(sb, "Serial required", FormatKB(stmt.MemoryGrant.SerialRequiredKB)); } - if (stmt.OptimizationLevel != null) - WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); if (stmt.CardinalityEstimationModel > 0) WriteRow(sb, "CE Model", stmt.CardinalityEstimationModel.ToString()); + if (stmt.OptimizationLevel != null) + WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); sb.AppendLine("
"); sb.AppendLine(""); diff --git a/src/PlanViewer.Web/Pages/InsightsPanel.razor b/src/PlanViewer.Web/Pages/InsightsPanel.razor index 4ccdfcc0..836adc12 100644 --- a/src/PlanViewer.Web/Pages/InsightsPanel.razor +++ b/src/PlanViewer.Web/Pages/InsightsPanel.razor @@ -8,7 +8,8 @@

@(isEstimatedRuntime ? "Predicted Runtime" : "Runtime")

- @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost *@ + @* Order per Joe #215 E11: Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → CE Model → Optimization → Cost. + #613 moved CE Model above Optimization, the same order as the App's Runtime Summary. *@ @if (Stmt.QueryTime != null) {
@@ -86,18 +87,18 @@
} } - @if (Stmt.OptimizationLevel != null) + @if (Stmt.CardinalityEstimationModel > 0) {
- Optimization - @Stmt.OptimizationLevel + CE Model + @Stmt.CardinalityEstimationModel
} - @if (Stmt.CardinalityEstimationModel > 0) + @if (Stmt.OptimizationLevel != null) {
- CE Model - @Stmt.CardinalityEstimationModel + Optimization + @Stmt.OptimizationLevel
}
diff --git a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs index f316bdbb..b4734ac7 100644 --- a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs +++ b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs @@ -115,6 +115,24 @@ public void Export_CraftedSeverityOnAnOperator_IsMappedToo() Assert.Contains("
", html); } + /// #613: the runtime card lists CE Model above Optimization, the same order as the App's Runtime Summary. + [Fact] + public void Export_RuntimeCard_ListsCeModelAboveOptimization() + { + var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan"); + foreach (var batch in plan.Batches) + foreach (var stmt in batch.Statements) + PlanLayoutEngine.Layout(stmt); + + var result = ResultMapper.Map(plan, "key_lookup_plan.sqlplan"); + var html = HtmlExporter.Export(result, TextFormatter.Format(result)); + + var ceModel = html.IndexOf("CE Model", StringComparison.Ordinal); + var optimization = html.IndexOf("Optimization", StringComparison.Ordinal); + Assert.True(ceModel >= 0, "no CE Model row"); + Assert.True(optimization > ceModel, "Optimization is not below CE Model"); + } + private static string ExportWithSeverity(string? severity, bool onOperator = false) { var warning = new WarningResult { Severity = severity!, Type = "demo", Message = "demo" }; diff --git a/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs b/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs new file mode 100644 index 00000000..d9721de9 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/RuntimeSummaryEarlyAbortTests.cs @@ -0,0 +1,94 @@ +using System.IO; +using System.Linq; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using PlanViewer.App.Controls; + +namespace PlanViewer.Core.Tests; + +/// +/// #613: the early abort reason is part of the optimization result (StatementOptmEarlyAbortReason +/// only exists for a FULL optimization), so the Runtime Summary shows it as a detail of the +/// Optimization row: on the next row, with its label indented and its value in the same column. +/// CE model moved up above Optimization, so Optimization and its reason end the list. +/// +public class RuntimeSummaryEarlyAbortTests +{ + [Fact] + public void EarlyAbort_IsNestedUnderOptimization() + { + HeadlessUi.Run(() => + { + var viewer = LoadPlan(stripOptimizationLevel: false); + var window = new Window { Content = viewer, Width = 1400, Height = 900 }; + window.Show(); + window.UpdateLayout(); + + var grid = SummaryGrid(viewer); + var ceModel = Label(grid, "CE model"); + var optimization = Label(grid, "Optimization"); + var earlyAbort = Label(grid, "Early abort"); + + Assert.Equal(Grid.GetRow(ceModel) + 1, Grid.GetRow(optimization)); + Assert.Equal(Grid.GetRow(optimization) + 1, Grid.GetRow(earlyAbort)); + Assert.Equal(grid.RowDefinitions.Count - 1, Grid.GetRow(earlyAbort)); + Assert.True(earlyAbort.Margin.Left > optimization.Margin.Left, + $"Early abort label is not indented: {earlyAbort.Margin.Left} vs {optimization.Margin.Left}"); + Assert.Equal("GoodEnoughPlanFound", Value(grid, earlyAbort)); + }); + } + + [Fact] + public void EarlyAbort_WithoutAnOptimizationRow_IsNotIndented() + { + HeadlessUi.Run(() => + { + var viewer = LoadPlan(stripOptimizationLevel: true); + var window = new Window { Content = viewer, Width = 1400, Height = 900 }; + window.Show(); + window.UpdateLayout(); + + var grid = SummaryGrid(viewer); + Assert.DoesNotContain(grid.Children.OfType(), t => t.Text == "Optimization"); + + var earlyAbort = Label(grid, "Early abort"); + var elapsed = Label(grid, "Elapsed"); + Assert.Equal(elapsed.Margin.Left, earlyAbort.Margin.Left); + }); + } + + /// + /// key_lookup_plan.sqlplan is an actual plan with StatementOptmLevel="FULL" and + /// StatementOptmEarlyAbortReason="GoodEnoughPlanFound". Removing the level leaves a reason + /// with no row to nest under. + /// + private static PlanViewerControl LoadPlan(bool stripOptimizationLevel) + { + var path = Path.Combine("Plans", "key_lookup_plan.sqlplan"); + Assert.True(File.Exists(path), $"Test plan not found: {path}"); + var xml = File.ReadAllText(path).Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + + const string level = "StatementOptmLevel=\"FULL\""; + Assert.Contains(level, xml); + Assert.Contains("StatementOptmEarlyAbortReason=\"GoodEnoughPlanFound\"", xml); + if (stripOptimizationLevel) + xml = xml.Replace(level, ""); + + var viewer = new PlanViewerControl(); + Assert.True(viewer.LoadPlan(xml, "key_lookup_plan.sqlplan"), $"Plan failed to load: {viewer.LastLoadError}"); + return viewer; + } + + private static Grid SummaryGrid(PlanViewerControl viewer) + { + var panel = viewer.GetLogicalDescendants().OfType().First(p => p.Name == "RuntimeSummaryContent"); + return (Grid)panel.Children.Single(); + } + + private static TextBlock Label(Grid grid, string label) + => grid.Children.OfType().Single(t => Grid.GetColumn(t) == 0 && t.Text == label); + + private static string Value(Grid grid, TextBlock label) + => grid.Children.OfType() + .Single(t => Grid.GetColumn(t) == 1 && Grid.GetRow(t) == Grid.GetRow(label)).Text ?? ""; +}