Skip to content

AG tab refreshes cards in place and virtualizes the list (#4238) - #4308

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/4238-ag-tab-in-place-refresh
Sep 25, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/4238-ag-tab-in-place-refresh

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #4238.

Why

The Darling viewer's Availability Groups tab tore down and rebuilt every card and every per-database DataGrid on each 30 s refresh, on the UI thread. Nothing was virtualized. Render assigned a brand new List<AgTopologyCard> to AgCards.ItemsSource every time, made of brand new card instances, whether or not anything had changed. The card list was a plain ItemsControl inside a ScrollViewer, so every card realized whether or not it was on screen. #4238 measured a production topology of 42 AGs, 84 replicas and 398 database rows. There, each refresh realized 10,353 elements and stalled the UI thread for 2,548 ms.

This PR's pin tests were written first and run against the code before the fix, to confirm the cause. 4 of the 5 new tests failed. One of them checks that two refreshes with the same rows keep the same card instances. So the old code replaced every card object on every refresh, whether or not its data changed.

What changes

PerformanceMonitor.Common/AgTopology.cs is shared by the Darling viewer and Lite. A search of the whole repo found that the web dashboard does not use these types.

  • AgTopologyCard, AgTopologyReplica, and AgTopologyDatabase now implement INotifyPropertyChanged, through a small shared AgTopologyObservable base. It stays plain BCL, no WPF dependency. Each type now has an UpdateFrom method. It copies every field from a newly read instance with the same identity, then raises one change notification for all properties.
  • AgTopology.Reconcile<TItem, TKey> is new. It syncs an ObservableCollection<TItem> to a freshly read list, in the fresh list's order, and reuses existing instances (through UpdateFrom) for every identity that survives. It adds new instances and removes stale ones. It is reused at three levels: a tab's top-level card list, and, inside AgTopologyCard.UpdateFrom, a card's own nested Replicas and Databases collections. That gives one reconciliation rule instead of three near-identical ones.
  • AgTopology.CardKey is new: a card's identity is its reporting server plus its normalized AG name, the same pair BuildCards already groups by.
  • AgTopology.ComputeDigest is new: a fingerprint of every field a card renders, including both grains' collection times. A refresh whose digest matches the last render skips the update.
  • BuildCards behavior is unchanged. Only its internal construction of a card changed, because Replicas/Databases are now get-only ObservableCollections populated with Add, not an object-initializer assignment.

Darling viewer, AvailabilityGroupsTab.xaml and .xaml.cs:

  • AgCards now virtualizes. Its ScrollViewer moved inside the ItemsControl's own ControlTemplate, wrapping CanContentScroll="True" around an ItemsPresenter, with a VirtualizingStackPanel (IsVirtualizing="True", VirtualizationMode="Recycling", ScrollUnit="Pixel"). A ScrollViewer wrapped around a plain ItemsControl from outside gives the panel unbounded height and never actually virtualizes. The fix had to move the ScrollViewer inside the template, not just add virtualization properties to the old structure. The named CardScroller element is gone. Its visibility toggle now targets AgCards directly.
  • The tab owns one long-lived ObservableCollection<AgTopologyCard>, bound to AgCards.ItemsSource exactly once, in the constructor. LoadAsync computes a digest of the freshly read rows and returns early when it matches the last render. Render reconciles that collection in place through AgTopology.Reconcile, instead of replacing ItemsSource. Render is now internal, mirroring the existing BuildSummary precedent, so tests can drive it directly without a live store.

Lite, Lite/Controls/AvailabilityGroupsTab.xaml and .xaml.cs: Lite shows Availability Groups the same way, with the same defect: AgCards.ItemsSource = cards wholesale, a plain ItemsControl in a ScrollViewer. It gets the same fix, the same shared AgTopology.Reconcile / ComputeDigest / UpdateFrom mechanism, and the same XAML virtualization change.

Tests: Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs (new) and Lite.Tests/AvailabilityGroupsTabRefreshTests.cs (new). Details below.

Test plan

  • The pin tests ran once against the code before the fix, to prove the defect. Darling.Tests -class "*AvailabilityGroupsTabRefreshTests*" : 4 of 5 failed. The same-rows-same-instance, changed-value-updates-in-place, add/remove-one-card, and XAML virtualizing-panel pins all failed against the old ItemsSource = code. Only the timing pin passed. The note below explains why.
  • dotnet build Darling/Darling.Tests/Darling.Tests.csproj: 0 Warning(s), 0 Error(s).
  • After the fix, the changed classes ran with the viewer's other STA test class, because they share the dispatcher: Darling.Tests -class "*AvailabilityGroupsTabRefreshTests*" -class "*AgTopologyCardsTests*" -class "*RawWindowFloorViewerPortTests*". 34 total, 0 failed.
  • dotnet build Lite.Tests/Lite.Tests.csproj: 0 Warning(s), 0 Error(s).
  • Lite.Tests -class "*AvailabilityGroupsTabRefreshTests*" -class "*AgTopologyTests*" -class "*AgAvailabilityGroupNewestSnapshotEqualityTests*". 30 total, 0 failed.
  • This PR changes Lite, so the full Lite suite ran once: Lite.Tests.exe, no filter. 5,392 total, 0 failed, 0 errors, 314.2 s.
  • The timing pin uses the WPF Availability Groups tab rebuilds every card on each 30 s refresh: 10 k realized elements (42 AGs, no virtualization) stall the UI thread ~2.5 s per tick #4238 production shape: 42 AGs, 84 replicas and 398 database rows. A refresh with unchanged rows on an STA thread took about 4.9 ms, against a 100 ms budget.
  • No full Darling suite run. This PR changes only viewer XAML, viewer code-behind, and a PerformanceMonitor.Common model file that no live or store test reads. Only the pure banding/projection tests read it, and those run in the classes above. CI runs the rest.
  • GitHub Actions CI on f06b9b2d: build, Darling PostgreSQL tests and Lite tests all pass.

A note on the timing number: the pin runs headless, with no Measure/Arrange/UpdateLayout. It never makes WPF realize the visuals, so it passed even on the code before the fix. Without a layout pass, neither version pays the cost of creating containers, which caused the 2,548 ms that #4238 measured in the field. The 100 ms budget is still a real, permanent regression guard on the reconcile/digest code path. The object-identity pins are what actually prove the rebuild-on-every-refresh defect is gone: they fail before this fix and pass after it.

CHANGELOG entry

SECTION: Fixed
ENTRY:

🤖 Generated with Claude Code

https://claude.ai/code/session_01FVjn4PBJN71NQXdFo6ZxNQ

erikdarlingdata and others added 4 commits September 25, 2026 11:38
The Darling viewer's Availability Groups tab tore down and rebuilt every
card and every per-database DataGrid on each 30 s refresh, on the UI
thread, with no virtualization (10,353 realized elements and a 2,548 ms
stall on a 42-AG production topology).

AgTopologyCard/Replica/Database (PerformanceMonitor.Common) now raise
PropertyChanged and expose UpdateFrom, so a refresh can update an
existing card's values in place instead of replacing it. AgTopology
gains a shared Reconcile helper (keyed by (reporting server, AG) for
cards, and reused for a card's nested replica/database rows) and a
ComputeDigest fingerprint so a refresh whose rows hash the same as the
last render skips reconciling and re-binding entirely. The tab now
binds AgCards to one long-lived ObservableCollection<AgTopologyCard>
instead of assigning a fresh List every refresh.

AgCards.xaml moves its ScrollViewer inside the ItemsControl's own
template (CanContentScroll="True" around an ItemsPresenter) and sets
VirtualizingStackPanel with Recycling/Pixel virtualization, so only
on-screen cards realize their Border/replica-chip/DataGrid subtree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Same-instance pin across two identical-row refreshes (fails on the old
ItemsSource-replace code, proven once before this fix landed), a
changed-value-updates-in-place pin, an added/removed-AG pin, a XAML pin
that AgCards uses a virtualizing panel, and a timing pin over the
production shape (42 AGs, 84 replicas, 398 database rows) asserting an
unchanged-rows refresh stays under 100 ms on the UI thread.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
Lite's Availability Groups tab had the identical defect: AgCards.ItemsSource
= cards, a brand new list of brand new AgTopologyCard instances on every
refresh, no virtualization. It uses the same shared mechanism the Darling
viewer's tab now uses (AgTopology.Reconcile / ComputeDigest / UpdateFrom),
plus the same virtualizing-panel XAML change. Pinned with Lite's own
same-instance, XAML and timing tests; the shared reconcile/digest
behavior itself is pinned once, in Darling.Tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 16:21
@erikdarlingdata
erikdarlingdata merged commit c88fa48 into dev Sep 25, 2026
18 of 20 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4238-ag-tab-in-place-refresh branch September 25, 2026 16:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant