Skip to content

skip the blocked-process/deadlock XE ring-buffer shred when nothing new arrived (#4200) - #4212

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/4200-xe-shred-gate
Sep 25, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/4200-xe-shred-gate

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #4200.

Why

blocked_process_report and deadlocks each read a dedicated, single-event XE ring-buffer
session. Every run cast the target's target_data to XML and shredded it with .nodes(...),
then filtered by timestamp. This ran whether or not anything new had arrived. The issue
measured a production fleet (43 servers, 6 hours). Only 0.02% of blocked_process_report runs
and 1.35% of deadlocks runs stored a row. The rest paid the full cast and shred to store
nothing. That cost about 4 hours a day of SQL time on the monitored servers of one estate.

What changes

Both collectors now read the session target's own execution_count before the cast, in the
same batch. The read comes from sys.dm_xe_session_targets joined to sys.dm_xe_sessions by
session address (the Azure SQL DB database-scoped twins on the per-database path). Each of
these two sessions carries exactly one event type. That is confirmed against
DarlingXeSessions.cs: one ADD EVENT per session. So the counter is an exact signal for
whether anything arrived since the last run, and reading it costs one integer with no XML
materialized.

The cast and shred now run only when the counter does not exactly match the prior cycle's own
reading of it. Every other case shreds exactly as before. A first run has no stored prior. A
restarted host or a store that lost the row also has no stored prior. The counter can be
unreadable this cycle when the session or target is missing or mid-provisioning. The counter
can grow, the ordinary case with new data. The counter can fall when the session restarted or
was recreated, so the buffer no longer matches what the prior was measured against.

Only an exact match skips the cast and shred. When it does skip, the rest of the batch still
runs: the shred step, the object-resolution updates, the final projection. It runs against an
empty source and produces the same zero rows a full shred of an unchanged buffer produces. The rows a run stores stay identical to today's. The gate only skips runs that stored nothing.

Each collector's own ReadAsync always reads a new trailing result set
(execution_count, gated) through NextResultAsync, before it returns. It writes the
observed count to CollectorContext.PendingState. That carries this cycle's count forward as
next cycle's @last_execution_count, whether or not this cycle shredded. On Azure SQL DB the
state is keyed per database through XeShredGate.KeyFor. Each database's session dispatches
independently. This mirrors PerDatabaseWatermarkColumn, which both collectors already use
for the same reason.

The deadlocks collector's Azure path also reads a durable, file-backed telemetry blob
(sys.fn_xe_telemetry_blob_target_read_file) in a UNION ALL alongside the ring-buffer read.
That branch has no execution_count of its own. It is not gated and keeps running on every
cycle exactly as before. A pin (Deadlocks_Azure_TelemetryBlobBranch_IsNotGated) checks that
the telemetry read sits outside the gate's IF block.

Both collectors are shared definitions in PerformanceMonitor.Collectors. Darling and Lite
both run them through the same BuildQuery/ReadAsync. There is one implementation, so Lite
parity holds by construction here rather than needing a second copy kept in sync. Lite's own
files (RemoteCollectorService.*.cs) own only XE session lifecycle, not the read.

Deprecated Dashboard. install/22_collect_blocked_processes.sql and
install/24_collect_deadlock_xml.sql have the identical pattern: an unconditional
TRY_CAST(xet.target_data AS xml) then ring_buffer.nodes(...), confirmed by grep. Per the
brief, this PR does not touch install scripts. Flagging it here for the coordinator to decide
whether the deprecated Dashboard still gets the same fix.

Files

  • PerformanceMonitor.Collectors/XeShredGate.cs (new): the shared, pure gate. It has
    KeyFor, ReadLast, ShouldShred, and ToStateValue.
  • PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs and
    PerformanceMonitor.Collectors/DeadlocksCollector.cs: the gate SQL, StateKeys, the
    trailing result set, and ReadAsync's consumption of it.
  • Test files updated for the new @last_execution_count parameter and the new trailing
    result set's position ahead of the existing probe-failure set. See Test plan.

Test plan

  • Pure gate tests in Lite.Tests/XeShredGateTests.cs
    (ShouldShred_MatchesTheFiveGateShapes, a [Theory]) cover all five shapes from the issue.
    A first run with no current and no prior count shreds. No stored prior shreds. An unreadable
    current count shreds. An unchanged count skips. A count that grew shreds. A count that fell
    shreds. The same file also pins ReadLast (an absent, blank, or malformed stored value all
    read as no prior) and KeyFor (the server-scoped key versus the per-database compound key,
    with no collision between them).
  • Pin: each collector consults the gate before the shred.
    BlockedProcessReport_BuildQuery_ReadsExecutionCountBeforeTheCast and
    Deadlocks_BuildQuery_ReadsExecutionCountBeforeTheCast cover both the server-scoped and
    Azure query text. Each asserts the execution_count read and the IF @shred_needed = 1
    gate appear in the generated SQL text before TRY_CAST(xet.target_data AS xml). A further
    pin checks the telemetry-blob branch stays outside that IF block. A parameter-wiring pin
    (BuildQuery_PassesTheStoredPrior_PerServer_AndPerDatabase) checks that a value stored in
    context.State reaches the bound @last_execution_count parameter.
  • ReadAsync round-trips PendingState. Unit tests using
    FakeCollectorDataReader.WithResultSets prove ReadAsync reads the trailing
    (execution_count, gated) result set and writes PendingState, for both the server-scoped
    and the per-database key. A NULL current count writes nothing, so a bad read this cycle
    cannot fabricate a false "unchanged" reading next cycle. Measure(ShredGatedMeasurement, ...) reflects the gate's own gated bit.
  • Proved each pin red against origin/dev. XeShredGate and every SQL fragment the pins
    check for (@last_execution_count, @shred_needed, @execution_count) do not exist on
    origin/dev. Every new test fails to even compile there. That is the strongest form of red
    for a purely additive change.
  • In-lane fixes for the new trailing result set's position. These were found by running
    the full suite. They sit outside the original brief, but are mechanical and land in files
    this PR already touches.
    • CollectorMeasurementSeamTests.EveryMeasurementLabelInTheCollectorsIsLegal and
      CollectorStateContractTests.TheCollectorsDeclaringStateArePinned in Darling.Tests, plus
      the matching Lite.Tests pin in DefaultTraceEventsCollectorDefinitionTests.cs, are census
      tests. Each lists every collector-declared measurement label or state key by hand. Both
      got the new shred_gated label and the two collectors' new StateKeys entry added.
    • DarlingBlockedProcessReportUnresolvedReasonTests.ReadAsync_Stops_At_Its_Payload_So_The_Trailing_Set_Survives_For_The_Host
      and its Lite.Tests mirror in BlockedProcessReportUnresolvedReasonTests build a real
      multi-result-set reader. They prove ReadAsync stops at the payload and leaves the
      probe-failure set for the host's ReadPayloadProbeFailuresAsync. The new gate result set
      now sits between those two sets. Both fakes needed a third table added at the right
      position. Without it, ReadAsync's own new NextResultAsync call would consume the
      probe-failure set by mistake instead. Both tests failed right after the production change
      and before the test-fake fix, and both pass after it.
    • Two existing BuildQuery_PinsWatermarkCutoff_AndTenMinuteFallback tests, in
      BlockedProcessReportCollectorDefinitionTests and DeadlocksCollectorDefinitionTests,
      asserted exactly one bound parameter. Both now select @cutoff_time by name and assert a
      count of two parameters.
  • Built Darling.Tests and Lite.Tests. Both report 0 Warning(s), 0 Error(s).
  • Ran the full suite once for each project. Darling.Tests: Total 13741, Errors 0, Failed
    0, Skipped 689, Not Run 1, 93.2s. Lite.Tests: Total 5332, Errors 0, Failed 0, 255.6s.
  • Not run: any live SQL Server test. The brief rules out every available SQL Server here.
    The local sql20xx instances count as monitored, and no other instance was available. So the
    actual server-side behavior (unchanged skips the cast, changed does not) is unverified
    against a live engine, as opposed to the SQL-text-order pin and the pure decision-table
    tests above. sys.dm_xe_session_targets.execution_count's semantics come from the issue's
    own research, not independently confirmed here.
  • Not created: a Darling.Tests-only mirror of the pure gate tests. Both collectors'
    BuildQuery and ReadAsync are a single shared implementation in
    PerformanceMonitor.Collectors. Lite's own files own only XE session lifecycle. So the
    Lite.Tests coverage above exercises the exact code both products run. Darling.Tests was
    still touched, and is green, for the two census pins the new code affects.

CHANGELOG entry

SECTION: Fixed
ENTRY:

🤖 Generated with Claude Code

https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3

…n_count (#4200)

Both collectors cast+shred their whole ring buffer on every run even though a
healthy estate finds nothing new almost every time (0.02% / 1.35% of runs
stored a row, measured on a production fleet). Read execution_count from
sys.dm_xe_session_targets before the cast and skip the cast+shred when it
matches the prior cycle's own reading; any other case (grew, fell, unreadable,
no prior) shreds exactly as before, so stored rows are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TszxYhJJbTEh4LrZ56NYo3
@erikdarlingdata erikdarlingdata changed the title DO NOT MERGE (XE shred gate): skip the blocked-process/deadlock XE ring-buffer shred when nothing new arrived (#4200) skip the blocked-process/deadlock XE ring-buffer shred when nothing new arrived (#4200) Sep 25, 2026
@erikdarlingdata
erikdarlingdata marked this pull request as ready for review September 25, 2026 03:31
@erikdarlingdata
erikdarlingdata merged commit 5d20ff0 into dev Sep 25, 2026
21 of 22 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4200-xe-shred-gate branch September 25, 2026 03:32
erikdarlingdata added a commit that referenced this pull request Sep 26, 2026
…nt (#4213) (#4371)

* Gate the deprecated Dashboard's XE ring-buffer shred on execution_count (#4213)

install/22_collect_blocked_processes.sql and install/24_collect_deadlock_xml.sql cast the whole
ring_buffer target to XML and shred it on every scheduled run, even when nothing new has arrived
(#4200's defect, fixed for Darling and Lite in #4212). Both collectors now read the target's own
execution_count before the cast and compare it against a value carried in a new
config.xe_shred_state table; a match skips the conversion and shred entirely. A missing count, no
stored count, or a count that has gone down (session restart) still does a full read.

Adds config.xe_shred_state (install/03_create_config_tables.sql) and a source-guard test class
(deprecated/Installer.Tests/XeShredGateSqlTests.cs) that pins the gate's presence in both
collector scripts and fails against the pre-fix file. Also brings
deprecated/Installer/README.md:3 in line with the root README's v3.3.0 release-assets note.

Closes #4213.

* Add two upgrade-path pins and fix an undeclared @shred_needed in the XE gate (#4213)

XeShredGateSqlTests gains two source-text assertions: config.xe_shred_state
is created with the repo's existing IF OBJECT_ID(...) IS NULL idiom, and a
missing state row means both collectors fail open to a full read rather
than skipping real data.

Measuring the gate against a throwaway container turned up a real defect
the pins do not cover: @shred_needed was referenced inside the dynamic SQL
string in both collectors' server-scoped and Azure branches but never
declared there, so every live run threw 'Must declare the scalar variable'
and fell through to the CATCH block. Declared it locally in each dynamic
batch.

* Add source-text pins for the dedupe predicate and dynamic-SQL DECLARE coverage (#4213)

XeShredGateSqlTests gains two source guards:

CollectorsDedupeReShreddedEventsOnInsert pins the NOT EXISTS predicate both
collectors' payload INSERT already carries (unchanged since before this PR,
matching origin/dev) so a changed execution_count's full re-shred does not
re-insert events already stored. Confirmed present at head for both
platform branches of both collectors, so no dedupe fix was needed in this
PR -- the predicate already exists.

DynamicSqlBatchesDeclareOrParameterizeEveryVariableTheyReference parses
each sp_executesql call's SQL text and params string and asserts every
@variable referenced inside the SQL text is either DECLAREd there or
listed as a parameter. Confirmed RED against f8ded1e (this PR's own
first commit, before the DECLARE fix) with the exact failure the live run
hit: @shred_needed referenced without a local DECLARE in all 4 dynamic
batches. GREEN at head.
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