Skip to content

Ask the narrower question once, instead of in every handler - #31

Merged
ivanvyd merged 1 commit into
mainfrom
feature/health-changed-event
Jul 29, 2026
Merged

Ask the narrower question once, instead of in every handler#31
ivanvyd merged 1 commit into
mainfrom
feature/health-changed-event

Conversation

@ivanvyd

@ivanvyd ivanvyd commented Jul 29, 2026

Copy link
Copy Markdown
Owner

StateChanged has been listed as a known issue since the event existed — in CHANGELOG.md under Known issues and in the README roadmap under What is left:

StateChanged fires on every check rather than only when state changes, because state equality includes the last execution time.

It is not a defect, and "fixing" it would have broken the dashboard.

The dashboard is event-driven by design: it subscribes to StateChanged and updates one entry in place, with a full read only on first load. Every check updates that row's last-run time and pushes a blip onto its pulse strip. Raising the event less often would silently stop the board from updating between health transitions — which, given the 1s clock tick redraws anyway, would have looked like it still worked.

What was actually missing was a way to ask the narrower question. Without one, every handler that only cared about a component going down reached into the two states and worked it out itself. Two packages were doing that identically:

// AlertDispatcher.cs:144        // UptimeRecorder.cs:127
var previous = args.OldState.LastResult?.Health;
var current  = args.NewState.LastResult?.Health;
if (current is not { } health || previous == health) return;

Three lines, twice, is where a concept stops belonging to its readers and starts belonging to the type.

The change

PulseCheckerStateChangedEventArgs gains three computed properties:

member meaning
PreviousHealth health before, or null if no check had produced one
CurrentHealth health after, or null if there is still no result
HealthChanged whether the health itself moved

Both packages now read them. Purely additive — no existing member changed, no behaviour changed.

Two edges, both deliberate and both tested

A first result is a change. Going from nothing known to Healthy is the first thing anyone learns about the component; a rule that ignored it would stay silent through a cold start straight into an outage.

Losing a result is not. A state with no result says nothing about health, so treating it as a transition would raise an alert whose current health is nothing at all. This is why HealthChanged is CurrentHealth is not null && PreviousHealth != CurrentHealth rather than a plain !=.

Verification

374 tests green on both net8.0 and net10.0. The 32 existing alerting and uptime tests pass unchanged, which is the evidence that the refactor preserved behaviour rather than merely compiling.

Six new tests, mutation-checked — a passing test proves nothing until it has been seen to fail:

mutation tests failed
HealthChanged drops the null guard (plain !=) 1
any result at all counts as a change 2
CurrentHealth reads the old state 12
the alert reports the new health as the old one 1
uptime records a segment for every check 1

Note on ordering

Cut from main, so it does not depend on #30. Both branches delete a different bullet from the same Known issues list, so whichever merges second may need a one-line conflict resolution there. Nothing else overlaps.

No release — version stays at 3.1.4.

StateChanged has been listed as a known issue since the event existed: it is
raised on every check rather than only when something meaningful changes,
because a stored result always moves the last execution time and that counts
towards state equality.

It is not a defect. The dashboard redraws from exactly those events -- every
check updates a row's last-run time and pushes a blip onto its pulse strip --
so raising it less often would break the thing the event mainly exists for.
What was missing was a way to ask the narrower question, and without one,
every handler that only cared about a component going down reached into
OldState and NewState.LastResult?.Health and worked it out for itself. The
alerting and uptime packages were doing that identically, three lines each,
which is the point at which a concept belongs to the type rather than to its
readers.

PulseCheckerStateChangedEventArgs now carries PreviousHealth, CurrentHealth
and HealthChanged, and both packages read them.

Two edges are deliberate. A first result is a change: going from nothing
known to Healthy is the first thing anyone learns about the component, and a
rule that ignored it would stay silent through a cold start into an outage.
Losing a result is not: a state with no result says nothing about health, so
treating it as a transition would raise an alert whose current health is
nothing at all.

The changelog and the README roadmap both described this as an outstanding
problem. They now describe what the event does and how to ask for less.
@ivanvyd
ivanvyd force-pushed the feature/health-changed-event branch from 8ad11f4 to 1f5d039 Compare July 29, 2026 19:30
@ivanvyd
ivanvyd merged commit 2bfbbe0 into main Jul 29, 2026
7 of 8 checks passed
@ivanvyd
ivanvyd deleted the feature/health-changed-event branch July 30, 2026 02:15
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