Skip to content

The alert pass retries a store read once, two seconds later, before counting it failed — a write band's twelve-second stall no longer blinds an alert (#3848) - #3851

Merged
erikdarlingdata merged 1 commit into
devfrom
feat/3848-alert-read-retry-once
Sep 21, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
feat/3848-alert-read-retry-once

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

The deadline is right; having no second attempt was the defect

DarlingAlertReadAdapter.AlertPassCommandTimeoutSeconds = 10 is the one value in its family deliberately set below what it inherited, and #2882's reasoning still holds in full: this pass has no enclosing CancelAfter, so the per-command deadline is the pass budget multiplied by however many reads run in sequence, and a read that runs long holds one of only MaxConcurrentServerSweeps fleet permits while the sweep skips relaunch for that server. Erring short is correct. Nothing here moves that number.

What the week's evidence adds is the shape of the failures it produces. Every alert-read kill on the largest production store was a sparse transient inside one of the store's own write bands — the fleet-overview fan-out racing into the flush band, the raw-hypertable compression band at steady state, timed-checkpoint fsync tails of 14–25 s, the daily-tier materialization's WAL storm, and the third band: the hourly successor refreshes materializing ~2 M rows/hour at evening peak with checkpoint syncs running 8–25 s chronically across the hour. Three client-deadline cancels in the 19Z hour alone, and the rate scales with the evening workload.

Each one is measured in seconds. The monitoring seat's own reads at the same minutes prove the point: its retry-once-sequential discipline lost no read all week. The alert pass had no retry at all, so a read that crossed 10 s was recorded by AlertReadFailureCounter (#3013), its condition was skipped for that cycle, and the next pass 30 s later ran into the same band still on.

So: re-ask once, after two seconds, on a command timeout and nothing else.

One seam, not fourteen edits

This type issues nineteen commands across twelve public reads, and the retry decision is one decision — fourteen copies of it would be free to disagree the moment any of them grew an arm, which is the argument PostgresTransportFault already makes for the write side's two callers. ExecuteWithOneRetryAsync takes each read whole as a delegate, so the retry re-runs from the read's connection-open outward rather than re-executing a half-consumed command. Every public read became a thin forwarder; each body moved verbatim to a private …CoreAsync sibling, SQL and parameters byte-identical.

Re-running a whole read is sound here because this type performs no accumulating write at all: the multi-command reads (database state's four maintenance statements ahead of its deviation read, anomalous jobs' freshness probe ahead of its page) are INSERT … ON CONFLICT DO NOTHING, an UPDATE to a fixed value, a DELETE of rows absent from the newest snapshot, and reads. That is a property of this file rather than a general licence — the same test StoreWriteReattempt.IsSafeToReattempt applies one seam over and answers differently, which is why its StoreCopyPhase conjunct is not reused: it is about a COPY's exactly-once property and has no meaning for a read.

The discrimination is the risky part, and it was measured rather than assumed

The gate must catch a client-side deadline and refuse three neighbours. The exception text cannot do this — Npgsql renders a client deadline as "Exception while reading from stream" with no SQLSTATE, identical to a dropped connection, which is the misdiagnosis #2826 exists to prevent. So the shapes were probed against the shipped Npgsql 10.0.3 on a throwaway postgres:17-alpine rig (SELECT pg_sleep(30) at CommandTimeout = 2):

case measured chain retried
client CommandTimeout expiry NpgsqlException("Exception while reading from stream") → TimeoutException("Timeout during reading attempt"), no SQLSTATE yes
…from ExecuteScalarAsync, ExecuteReaderAsync, and ReadAsync mid-stream identical chain in all three yes
server statement_timeout = 1500 under CommandTimeout = 60 bare PostgresException, SQLSTATE 57014, no TimeoutException anywhere no
caller token cancelled mid-statement OperationCanceledException → PostgresException 57014 ("user request") no
pre-cancelled token bare OperationCanceledException no
connect timeout NpgsqlException("Failed to connect to …") → TimeoutException("Timeout during connection attempt") yes, deliberately

IsCommandTimeout walks the chain with the PostgresException test first at every level, so a server reply wrapped in transport machinery answers false. It deliberately does not end in "any NpgsqlException" the way the transport predicate does — a bare one with no TimeoutException inside it is some other client fault, and a gate ending in a catch-all would drift into retrying everything the first time Npgsql adds a wrapper. It is also narrower than IsTransportFault on purpose: a reset socket and a torn stream are connection faults rather than a read taking too long, and a broken connection during an alert pass is a condition the count should keep saying out loud. The connect timeout is on the retry side because its chain is indistinguishable from a command deadline except by message text, and a store unreachable inside its connect timeout is the same transient population worth one more ask.

Two further measurements make the second attempt sound rather than a read against a poisoned socket: after every deadline case the connection is left State = Open and immediately reusable — including a second ExecuteReaderAsync on the same connection after a mid-stream kill — and the backend's statement is genuinely cancelled (pg_stat_activity held zero surviving pg_sleep backends one second later).

Counting both truths

AlertReadFailureCounter gains RecordRetriedRead and a RetriedReads count beside ReadFailures. A retried-and-succeeded read increments only the retry count — the condition was judged, on evidence that arrived late. A read that stalls twice increments the retry count and, through the caller's existing catch arm exactly as before, today's failure count.

That second case is a decision the pin records and documents: retries are counted per attempt-pair, not per success. A retry counted only when it worked would make the figure a success rate and would understate the write bands' cost by precisely the episodes where the band was worst — the confident-zero shape #3013 exists to remove. And the seam records no failure itself, so a stalled-twice read is one failure, never two, with the caller's elapsed still measuring its own last attempt.

alert_read_health gains retried_reads and instance_retried_reads as trailing fields on both SKUs' get_collection_health and as two tiles on the web panel. They carry no stamp, read name or elapsed, unlike every count above them — those three date and attribute a condition that went blind, and a retried read did not, so a trio would invite reading a retry as a soft failure. On Lite both read zero as a property of the SKU rather than of a quiet store: Lite's alerting reads carry no command deadline, so there is no deadline to cross and nothing to retry, and the note says exactly that.

What this does NOT do

The new arithmetic is stated in the constant's doc: a stalled read costs at most 22 s instead of 10 s, and the pass's unbudgeted worst case becomes 45 × 22 s only when every read on every server stalls twice — a fleet-wide store outage, which is exactly the case where this pass should be slow. TheRetryPause_StaysInsideThePassesOwnArithmetic pins that two attempts plus the pause still fit inside the 30 s sweep interval, so the "one cycle of delay" cost the deadline's asymmetry is argued from stays true.

Which reads were routed, and which were not

Routed — all twelve public reads on DarlingAlertReadAdapter: blocking (XE + DMV fallback), blocking wait time, deadlocks, poison waits, long-running queries, database file growth, volume free space, PVS pressure, TempDB space, anomalous jobs, database state, forced-plan failures. The name each is given at the seam is the same one the engine's own catch arm records a failure under, asserted from both sources so one read cannot carry two names across the two counts.

Not routed, deliberately — the seven commands DarlingSelfAlertEvaluator executes on the same constant (ReadCollectionSignalsAsync, ReadMissingCaptureSessionsAsync, HasAgentEverBeenSeenRunningAsync, ReadLatestAgentStatusAsync, ReadLatestAgReplicaStatesAsync, ReadLatestAgDatabaseReplicaStatesAsync). These are internal static methods taking an NpgsqlDataSource rather than instance methods on the adapter, with their own live-fixture pins calling them statically — routing them needs either a static seam duplicating the instance one, or threading the counter and delay through six signatures and their tests. That is more than a mechanical route, so they stay unretried and are recorded here as the follow-up. The two heaviest of them were already fixed at the read (#3496, #3597 made both chunk-orderable), which is why they are the cheaper half to leave.

Tests

Darling.Tests/AlertReadRetrySeamTests.cs, fourteen cases, all executed locally:

  • (a) a read that stalls once then answers → one result, RetriedReads = 1, ReadFailures = 0, and exactly one wait of exactly AlertPassRetryDelaySeconds — asserted as a value through an injected delay rather than by sleeping two real seconds.
  • (b) stalls twice → the second attempt's exception propagates (not chained onto the first), RetriedReads = 1, and the seam records no failure because that is the caller's arm.
  • (c) a theory over seven non-deadline faults → first attempt only, no wait, nothing counted: the store's statement_timeout, an ordinary server error, a server reply wrapped in a transport exception, a reset socket, a torn stream, a bare NpgsqlException, an unrelated fault.
  • (d) the pass token cancelled strictly inside the wait → OperationCanceledException, no second attempt, and the retry still counted because it was already spent. Plus both measured first-attempt cancellation shapes, which the arm ahead of the filter refuses.
  • (e) IsCommandTimeout against every measured chain in both directions, including a PostgresException outside a TimeoutException (a chain Npgsql does not produce today — pinned so the walk's correctness cannot come to depend on wrapping order) and the explicit disagreement with PostgresTransportFault.IsTransportFault on a reset socket.
  • (f) EveryPublicReadOnTheAdapter_GoesOutThroughTheSeam, derived from source: every public read must be a non-async forwarder calling the seam with a private …CoreAsync sibling, so a thirteenth read written in the old shape reds on the day it is written rather than shipping unretried and silently blind. Its count floor earned its place immediately — the first cut of its regex stopped at the first > and saw four of twelve reads, which is the guard-sees-a-third-of-its-population failure this file exists to assert against.

Updated existing pins: both SKUs' AlertReadFailureSurfaceTests (record members 14 → 16, rendered field set 16 → 18 with the two new names, payload fields 14 → 16), and the trio rule now asserts the retry pair is outside it in both directions rather than relying on a suffix filter to miss it.

Full census run green post-rebase: AlertReadRetrySeamTests 14, AlertReadFailureSurfaceTests 27, AlertPassCommandTimeoutTests 11, RepoFileAdoptionTests 2, McpPageContractTests 43, McpPayloadContractCensusTests 67, MeasurementContractCensusTests 10, FactSourceRegistryTests 5, StragglerCommandTimeoutTests 48, ViewerCommandTimeoutTests 57 — 0 failures. Whole solution -c Release -p:EnableWindowsTargeting=true: 0 warnings, 0 errors.

Closes #3848

…ounting it failed — a write band's twelve-second stall no longer blinds an alert (#3848)

Every alert-read kill on the largest production store over the week that
produced this issue was a sparse transient inside one of the store's own write
bands. The 10 s per-read deadline is doing what it was designed to do — a long
read holds a fleet-sweep permit — so the fix is reader-side: re-ask once, after
two seconds, on a command timeout and nothing else.

One seam in DarlingAlertReadAdapter, twelve public reads routed through it, and
the failure counter gains RetriedReads beside ReadFailures so the bands' cost
stays a visible count rather than a blind alert.
@erikdarlingdata
erikdarlingdata merged commit cd8d82c into dev Sep 21, 2026
7 of 8 checks passed
@erikdarlingdata
erikdarlingdata deleted the feat/3848-alert-read-retry-once branch September 21, 2026 21:16
erikdarlingdata added a commit that referenced this pull request Sep 22, 2026
… seam, as instance methods over one shared decision rather than a second copy of it (#3861)

* The alert pass's other seven store reads go out through #3848's retry seam, as instance methods over one shared decision rather than a second copy of it

#3851 gave the twelve reads on DarlingAlertReadAdapter one retry two seconds
after a command timeout. The same alert pass issues seven more store reads that
were not on that type at all — six internal statics on DarlingSelfAlertEvaluator
taking an NpgsqlDataSource, plus DarlingWorker.ReadLatestCpuAsync — every one of
them on the same AlertPassCommandTimeoutSeconds deadline and counted on the same
AlertReadFailureCounter. They stayed unretried because routing them needed either
a duplicate static seam or the counter and the delay threaded through six
signatures, which #3851's lane judged more than mechanical and recorded rather
than half-did.

The two heaviest were already fixed AT the read (#3496's chunk-order scan,
#3597's refresh convoy), so what was left exposed is the light ones during a
write band: a Collection Signals or Agent Status read that crosses ten seconds
skips its cycle and is never re-asked. The worker's latest-CPU read is the sharper
case — its own call site names it the FIRST store read of the pass, so under the
contention #3013 measured it is the first to fail, and a seam covering the
evaluator's six would have left the pass's first read as its only unretried one.

So ONE seam, not two truths. ExecuteWithOneRetryAsync keeps its instance
overload — the adapter's twelve forwarders and the census pin that derives from
their shape are untouched — and its body moves to a static overload taking the
counter and the pause as parameters. The evaluator forwards to that through a
private ReadWithOneRetryAsync over its own _readFailures; the worker calls it
directly. The discrimination, the cancellation arm ordered ahead of the filter,
the single attempt-pair count and the pass token honoured during the pause are
therefore defined once for every retried read in the service. A second seam here
would be a second copy of one decision, free to disagree the moment either grew
an arm: #3848's argument against fourteen copies inside one file, one scope out.

Static rather than an interface or a base class because the two callers share
nothing else — a read adapter over a fleet of SQL Server targets and the store's
own self-alert evaluator — and a shared base would exist solely to carry four
lines.

IsCommandTimeout is unchanged and still means a TimeoutException anywhere in the
chain and nothing else: never a PostgresException at any level, because the
store's own statement_timeout at 57014 is the backend ANSWERING and an identical
second attempt buys an identical answer; never a cancellation through the pass
token, because a retry outliving an orderly stop while holding a fleet-sweep
permit is guaranteed useless; never a reset socket or a torn stream, which are
connection faults rather than a read taking too long and which this pass's count
should keep saying out loud.

Each of the seven is now a thin non-async forwarder over a private …CoreAsync
sibling holding its body verbatim — SQL, parameters and shaping byte-identical.
The four read names the seven share are promoted to constants, at the CATCH arm's
granularity rather than the method's (the agent condition issues two reads inside
one try, the AG condition two), so a read cannot carry one spelling into the retry
count and another into the failure count.

No payload change: alert_read_health.retried_reads already counts whatever the
seam sees.

* The widened seam's pins: the seven reads' retry coverage, the surface prose, and the census that keeps the next read from bypassing it (#3854)

AlertReadRetrySeamTests grows the evaluator/worker half: each converted read
retries once on a command timeout, never on a store-returned fault, never
through the pass token, and tallies on the evaluator's own counter.
AlertReadFailureSurfaceTests keeps the instance-total prose honest about what
the seam now covers, CaptureDownChunkOrderTests and DarlingSelfAlertTests move
their static call sites to the instance seam, and the counter's doc says why a
fleet part is still a structural zero. Lite's counterpart surface test derives
its expectation from the source concatenation plus the live FleetScopedReads
value, so the prose edit holds there by construction - the assembly's fixtures
do not run on this box, so CI is its executor.
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