Repository navigation
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
Conversation
…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.
This was referenced Sep 21, 2026
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.
This was referenced Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The deadline is right; having no second attempt was the defect
DarlingAlertReadAdapter.AlertPassCommandTimeoutSeconds = 10is the one value in its family deliberately set below what it inherited, and #2882's reasoning still holds in full: this pass has no enclosingCancelAfter, 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 onlyMaxConcurrentServerSweepsfleet 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
PostgresTransportFaultalready makes for the write side's two callers.ExecuteWithOneRetryAsynctakes 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…CoreAsyncsibling, 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, anUPDATEto a fixed value, aDELETEof rows absent from the newest snapshot, and reads. That is a property of this file rather than a general licence — the same testStoreWriteReattempt.IsSafeToReattemptapplies one seam over and answers differently, which is why itsStoreCopyPhaseconjunct 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-alpinerig (SELECT pg_sleep(30)atCommandTimeout = 2):CommandTimeoutexpiryNpgsqlException("Exception while reading from stream")→TimeoutException("Timeout during reading attempt"), no SQLSTATEExecuteScalarAsync,ExecuteReaderAsync, andReadAsyncmid-streamstatement_timeout = 1500underCommandTimeout = 60PostgresException, SQLSTATE57014, noTimeoutExceptionanywhereOperationCanceledException→PostgresException57014 ("user request")OperationCanceledExceptionNpgsqlException("Failed to connect to …")→TimeoutException("Timeout during connection attempt")IsCommandTimeoutwalks the chain with thePostgresExceptiontest first at every level, so a server reply wrapped in transport machinery answers false. It deliberately does not end in "anyNpgsqlException" the way the transport predicate does — a bare one with noTimeoutExceptioninside 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 thanIsTransportFaulton 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 = Openand immediately reusable — including a secondExecuteReaderAsyncon the same connection after a mid-stream kill — and the backend's statement is genuinely cancelled (pg_stat_activityheld zero survivingpg_sleepbackends one second later).Counting both truths
AlertReadFailureCountergainsRecordRetriedReadand aRetriedReadscount besideReadFailures. 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_healthgainsretried_readsandinstance_retried_readsas trailing fields on both SKUs'get_collection_healthand 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
ServiceCommandDeadlines,TimescaleSupportand the alert engine's SQL Server-target reads are untouched; those are target reads, not store reads.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 sonly 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_StaysInsideThePassesOwnArithmeticpins 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
DarlingSelfAlertEvaluatorexecutes on the same constant (ReadCollectionSignalsAsync,ReadMissingCaptureSessionsAsync,HasAgentEverBeenSeenRunningAsync,ReadLatestAgentStatusAsync,ReadLatestAgReplicaStatesAsync,ReadLatestAgDatabaseReplicaStatesAsync). These areinternal staticmethods taking anNpgsqlDataSourcerather 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:RetriedReads= 1,ReadFailures= 0, and exactly one wait of exactlyAlertPassRetryDelaySeconds— asserted as a value through an injected delay rather than by sleeping two real seconds.RetriedReads= 1, and the seam records no failure because that is the caller's arm.statement_timeout, an ordinary server error, a server reply wrapped in a transport exception, a reset socket, a torn stream, a bareNpgsqlException, an unrelated fault.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.IsCommandTimeoutagainst every measured chain in both directions, including aPostgresExceptionoutside aTimeoutException(a chain Npgsql does not produce today — pinned so the walk's correctness cannot come to depend on wrapping order) and the explicit disagreement withPostgresTransportFault.IsTransportFaulton a reset socket.EveryPublicReadOnTheAdapter_GoesOutThroughTheSeam, derived from source: every public read must be a non-asyncforwarder calling the seam with a private…CoreAsyncsibling, 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:
AlertReadRetrySeamTests14,AlertReadFailureSurfaceTests27,AlertPassCommandTimeoutTests11,RepoFileAdoptionTests2,McpPageContractTests43,McpPayloadContractCensusTests67,MeasurementContractCensusTests10,FactSourceRegistryTests5,StragglerCommandTimeoutTests48,ViewerCommandTimeoutTests57 — 0 failures. Whole solution-c Release -p:EnableWindowsTargeting=true: 0 warnings, 0 errors.Closes #3848