Repository navigation
The daily digest and sweep rollup asked 'did I send' when the channel asks 'was it delivered': the once-a-day gate now stamps delivered-today in the store, so a restart re-announces nothing and a failed delivery still retries (#3580) - #3633
Conversation
…e, not fired-today in process memory: a restart no longer re-announces them, and a failed delivery still retries (#3580) Both once-a-day gates were a ConcurrentDictionary under one fixed key, so a fresh process delivered both documents again whatever the previous process had delivered an hour earlier — six re-announcements among ~23 channel posts on the v3.8.0 install night. The gate now reads a delivery stamp from collect.collector_state (V44, no rung; server_id 0 = the fleet sentinel both existing fleet-scope writers use, collector_name 'self_alert') and writes it only when the deliverer reports a disposition other than `failed`, so the install night's recovery case — a failed pair re-attempted after the restart and landing — is preserved by construction. - IAlertDeliverer.DeliverAndReportAsync: a default-implemented reporting twin of DeliverAsync (null = unreported); DarlingAlertDeliverer reports the AlertDelivery its history row was written with. - DarlingSelfAlertEvaluator: FireAsync returns the report; the two documents stamp AFTER the fire; the store seeds memory after a start (#981's cooldown seed shape), memory serves the process; a stamp read fault falls open to memory, warns and is counted (#3013); a stamp write fault warns, uncounted. - Census pins re-justified: AlertMasterSwitchSurfaceTests (new delivery seam name, generic FireAsync return), AlertReadFailureSurfaceTests (9th counted evaluator site, 12th exempt), Lite.Tests inventory (8th fleet-scoped read), AlertReadFailureCounter.FleetScopedReads names the new read. - Tests: SelfAlertDeliveryStampTests (both documents, simulated restarts, failed/muted/unreported dispositions, read/write faults) and a DARLING_TEST_PG-gated live round-trip through the real table.
| /// <para>No store configured (<see cref="_deliveryStamps"/> null) is the pre-#3580 gate exactly: memory | ||
| /// only.</para> | ||
| /// </summary> | ||
| private async Task<bool> DocumentDeliveredInsideIntervalAsync( |
There was a problem hiding this comment.
The class remarks for this method state the invariant: "the evaluate half's pre-check and the apply half's own check cost one store read between them and not two — the first seeds, the second finds memory populated." That invariant only holds when the store read succeeds (returns a stamp or null for "no row").
When _deliveryStamps.GetDeliveredAtUtcAsync throws, the catch block here (below, in the method body) logs a warning, records one read failure via _readFailures?.RecordReadFailure(...), and returns false without populating lastDelivered. Both call sites are structured as "evaluate half calls this, and if it returns false falls through to eventually call the apply half, which calls this again" (EvaluateCollectorCostAsync → ApplyCollectorCostDigestAsync at lines 1237/1507; EvaluateFleetSweepRollupAsync → ApplyFleetSweepRollupAsync at lines 1700/1782). Since memory is never seeded on a thrown exception, the apply half's own check re-invokes the store read, which (for a persistent fault) throws again.
Net effect during a fault confined to collect.collector_state (e.g., lock contention, a slow/timing-out query on that one table while the rest of the store answers fine): every hourly tick logs two "delivery stamp could not be read" warnings and increments the #3013 swallowed-read counter twice, for what is really one logical failure — double the log noise and a 2x-inflated failure count in AlertReadFailureCounter, for as long as the fault persists. This contradicts the documented "one store read between them" cost model and isn't exercised by SelfAlertDeliveryStampTests, since those tests drive ApplyCollectorCostDigestAsync/ApplyFleetSweepRollupAsync directly rather than through the Evaluate*Async wrappers that chain into them.
Same null-no-row case too (a document that has literally never delivered, e.g. brand-new install): stamped is not DateTime also returns without caching, so the apply half hits the store again — this one's just an extra round trip, not a double failure count, but same root cause.
Worth either caching a "known unreadable this tick" sentinel (or reusing now/a local flag) so the apply half's check short-circuits once the evaluate half has already tried and failed, or accepting this as a known, bounded gap in the cost/count model and saying so in the doc comment.
There was a problem hiding this comment.
Taken, in e60fb26 — a real hole in the cost model, and the fix is the one you named, taken one step further. The gate now caches whatever the store answered on the first ask, INCLUDING "nothing": no row, or a read that threw, stores a NoDeliveryKnown sentinel (DateTime.MinValue, compared by identity before the interval arithmetic so it can never be mistaken for a stamp), so the store is asked exactly once per document per process. The apply half's own check, seconds after the evaluate half's, finds memory populated whether the store answered, was empty, or threw — one read, one warning, one count per fault per process.
Per process rather than per tick, because it is exact under the single-writer fact the doc already leans on: a store that had no row for this process's first tick cannot gain one except through this process, which populates memory directly when a delivery lands. Re-asking on a fault would buy nothing either — the fail-open delivery has already happened by then, and memory is stamped by it. The retry the install-night recovery case needs is unaffected: a document that has never delivered reads "deliver" from memory on every tick until one lands.
Pinned: AStampReadFault_IsMetOnce_PerProcess_EvenWhenNothingLands drives two consults on one tick with BOTH the stamp read and the delivery failing (so nothing but the sentinel can be what stops the second consult) — one store read, one warning line, one #3013 count, both attempts made; the next hour still one read; a fresh process meets the fault once more. The no-row arm rides AFailedDelivery_WritesNoStamp…, which now asserts one read per process across three processes. The doc paragraph is rewritten to state the once-per-process model and why it holds in the fault case.
There was a problem hiding this comment.
Reviewed the delivered-today stamping mechanism for the collector-cost digest and fleet-sweep rollup (Darling-only; no Lite parity gap since neither document exists on Lite). The core design is sound — memory-first gate seeded from a store stamp, write-skipped only on AlertDelivery.ChannelFailed, fail-open on store faults — and the new tests exercise the install-night scenarios directly.
- Double-counted/double-logged store-read failures (
Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs,DocumentDeliveredInsideIntervalAsync, ~line 1859): the method's own doc comment claims the evaluate-half pre-check and the apply-half's own check cost "one store read between them and not two — the first seeds, the second finds memory populated." That only holds when the store read succeeds. WhenGetDeliveredAtUtcAsyncthrows, the catch block returnsfalsewithout populatinglastDelivered, so the apply half (called right after the evaluate half falls through) re-invokes the same store read. For a fault confined tocollect.collector_state, this doubles the "delivery stamp could not be read" warning and the #3013 swallowed-read counter increment per tick, for one logical failure — not exercised bySelfAlertDeliveryStampTests, which drives theApply*Asyncmethods directly rather than through theEvaluate*Asyncwrappers that chain into them. Left as an inline comment with detail.
Everything else checked out: parameterized SQL with no injection risk, schema-qualified collect.collector_state access, naive-UTC timestamp discipline followed correctly for updated_at, the fleet-sentinel/owner-name collision analysis against existing prune paths held up, and the AlertDelivery.FromFanout precedence confirms ChannelFailed is only reached when no channel actually delivered (so a partial email-success/webhook-failure doesn't wrongly withhold the stamp).
…ailed answer is cached as a sentinel, so the apply half's gate does not re-read, re-warn or re-count a fault the evaluate half already met on the same tick (#3580 review) Review catch: the "one store read between the two halves" cost model only held when the read succeeded — a throw (or no row) returned without seeding memory, so the apply half re-asked, doubling the warning and the #3013 count per tick for one logical fault. Exact under the single-writer fact: a store with no row on this process's first tick cannot gain one except through this process, which seeds memory directly. New pin drives two consults on one tick with both the read and the delivery failing — one read, one warning, one count.
Remediated in e60fb26: the stamp store is asked once per document per process (empty/failed answers cached as a sentinel), so the apply half no longer re-reads, re-warns or re-counts a fault the evaluate half met on the same tick; pinned by a new two-consults-one-tick test. Fresh review runs on the new head.
| /// failed reports <see cref="AlertDelivery.ChannelFailed"/>, which is the one disposition the askers | ||
| /// withhold their delivered-today stamp on.</para> | ||
| /// </summary> | ||
| async Task<AlertDelivery?> DeliverAndReportAsync(AlertOutcome outcome, CancellationToken cancellationToken = default) |
There was a problem hiding this comment.
DeliverAndReportAsync is added with a default interface implementation, but CONTRIBUTING.md's "Two-Store Parity" section names this exact interface as one that must not get defaulted members:
Declare interface members as required, not defaulted.
IAlertStateStore,IAlertEngineSettingsandIAlertDeliverergive no member a default implementation, so adding one makes the compiler name every implementer and every test fake... A default implementation compiles and leaves the ones you forgot quietly doing nothing.
Lite/Services/LiteAlertDeliverer.cs implements IAlertDeliverer but does not override DeliverAndReportAsync — it silently inherits this default. The PR's own docs argue this is deliberate (Lite has no daily documents to gate), and the outcome is currently correct, but it's exactly the pattern the guideline calls out: nothing forces a future Lite maintainer to notice this member exists or decide whether Lite should ever report a disposition. Since the guideline names IAlertDeliverer by name, was this considered, or should LiteAlertDeliverer get an explicit (even if trivially => default-delegating) override so the compiler keeps enforcing the parity contract per convention?
Not blocking — the current behavior is correct and well-tested — but worth a maintainer call given the guideline speaks to this exact interface.
There was a problem hiding this comment.
Taken — you are right, and CONTRIBUTING names this interface by name, which I should have read before reaching for a default. Auto-merge fired on this LGTM before the remediation reached the branch, so it lands as the follow-up: #3640 makes DeliverAndReportAsync REQUIRED, gives LiteAlertDeliverer an explicit null with the reason (its send seam returns no disposition; it hosts neither daily document), and writes the answer into all fourteen fakes by hand — the DarlingAlertingTests wrapper forwards the inner deliverer's report rather than swallowing it. No behaviour change; the compiler now names the fifteenth implementer.
|
Reviewed the diff (Darling-only: delivery-stamp store, evaluator gate rework, Correctness. The gate logic in Store/schema. Lite/Darling parity. This feature (collector-cost digest, fleet-sweep rollup) is Darling-only pre-existing; Lite has no equivalent and none is needed. The shared surface touched ( Tests. The new No blocking findings. |
There was a problem hiding this comment.
LGTM — reviewed the delivery-stamp gate logic (DocumentDeliveredInsideIntervalAsync/RecordDocumentDeliveredAsync), the AlertDelivery disposition handling, the Postgres store's schema/sentinel/prune-safety, and Lite/Darling parity. No blocking findings; one non-blocking CONTRIBUTING.md note left inline on IAlertDeliverer.cs about the default-implemented interface member.
…spliced once Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
…spliced once (#3672) Fifty-nine PRs merged to dev today across the coordinator's lanes and the wave-2 worker's; each lane returned its entry to a buffer instead of touching this file, so that fifty-plus PRs did not each rebase the same twenty lines. This is the one splice. Every entry is one line (the archiver's compact() and the pins read them that way); riders fold into their parent's entry (#3599 under #3590, #3619 under #3611, #3623 under #3616, #3640 under #3633; #3617 test-only and #3661 re-cut as #3666 carry none); #3657's entry is in because it MERGED to dev - the twin to main is what is still pending. [Unreleased] gains a `### Added` above `### Fixed` (Keep-a-Changelog order) for the six new capabilities: per-user theme colours (#3606 / #3577 arm B), routed alert families (#3668 / #3598), the PostgreSQL logging audit tool (#3643 / #3607), the service-side wait sampler (#3645 / #3604), and the log-event classifier with its temp-file / autovacuum parser families (#3646 / #3601, #3664 / #3602 #3603). The other forty-eight are honesty fixes to existing surfaces and append to `### Fixed` after the wave-1 bullets, in PR-number order. Thirty-six reference definitions added for the issues the new entries cite and the index did not yet define; the [Unreleased] group is one ascending run again, which moves [#3557] into its slot (the one deleted line). Nothing under ## [3.8.0] or older is touched; the archive script was not run. One editorial touch: the #3585 entry ended in a dangling "Darling" and now reads "Darling only." (the tool exists only in the Darling MCP host). tools/changelog/changelog_archive.py verify: all PASS (1420 bold entries, floor 1,329; 1,358 distinct refs resolve; CRLF throughout; 356,539 bytes under the 750 KiB ceiling). ChangelogIndexAndArchiveTests: 5/5 pass via a net10.0 harness.
The mechanism
Both daily documents — the collector-cost digest (#3443) and the fleet-sweep rollup (#3466) — gate on "once per day" through a
ConcurrentDictionary<string, DateTime>under one fixed key, and comparenow - lastSent < interval. That dictionary is process memory. A fresh process has an empty one, so the first hourly tick after ANY start reads "not sent today" and delivers both documents again, whatever the previous process delivered an hour earlier. The gate was answering "has THIS PROCESS sent one today" when the reader's question is "has one been DELIVERED today". Both docs said so out loud — "in-memory, like every sibling's interval, and a restart costs one extra digest" — and accepted it as the cheapest failure class.The evidence
The v3.8.0 install night priced that acceptance: three stores restarted once each, and the dogfood channel carried six re-announcements among ~23 overnight posts — a quarter of the channel was the same two documents, twice. The same night showed the opposite case working correctly and worth keeping: a digest/rollup pair whose delivery had FAILED (a transport fault, not a suppression) was re-attempted after the restart and landed. The restart was the recovery. A failed delivery is not a delivery.
Why this shape
Gate on delivered-today, recorded in the store. On a delivery the deliverer does not report
failed, the evaluator writes a stamp tocollect.collector_state— the store's existing key/value state table (V44), so no migration rung — and reads it back before firing. The gate skips whilenow - stamp < interval, restart or not. A failed delivery writes nothing, so the next tick — restart or not — retries: the install-night recovery case is preserved by construction rather than by a special case.The row's identity.
server_id = 0is the fleet sentinel the store already uses in two places —DarlingObservability.FleetServerIdfor the retention purge'scollection_logrun-record andFleetSweepStore.FleetScopeServerIdfor the sweep's fleet-scope watch items — both resting on the fact that server_ids are FNV-1a hashes and nothing is registered at 0; these two documents fire under the synthetic store label, not a server, so they take the same id rather than mint a third convention (and the column isNOT NULLin the PK, so a sentinel and not a NULL).collector_name = 'self_alert'is an owner name that is not a collector definition's — theQueryStoreBackfillprecedent — so no declared-key read and no per-database prune in the runner can reach it; every prune there is scoped to a real server_id AND a collector's own name. Two keys:digest_delivered_at,sweep_rollup_delivered_at. The value is ISO 8601 round-trip text with itsZ, so the read side getsKind=Utcback honestly;updated_atis the naive-UTC write time, the column's meaning on every other row.How the deliverer's answer reaches the fire site.
IAlertDeliverer.DeliverAsyncreturnsTaskand its contract is "never throw for channel failures" — correct for every condition-class alert and deliberately kept. The two documents need the disposition, soIAlertDeliverergrowsDeliverAndReportAsync, a default-implemented twin that returns theAlertDeliverythe deliverer recorded (ornull= unreported).DarlingAlertDelivererreports the exact disposition its history row was written with, so what the caller is told and what the operator later reads in the alert log cannot disagree;DeliverAsyncis that with the answer discarded, so there is one delivery path and not a reporting one beside a silent one. Lite's deliverer and every test fake keep the default and behave unchanged. The evaluator'sFireAsyncnow returns the report; every condition-class caller ignores it.What counts as delivered. The stamp is withheld on exactly one disposition,
AlertDelivery.ChannelFailed— a channel was attempted and came back unsuccessful, which is the install night's recovery case. Everything else is either a delivery (Sent) or the product's own decision that nothing is owed:muted(a mute rule chose the silence),unconfigured(no channel exists; the history row IS the delivery, and retrying hourly would write 24 rows a day for nothing),throttled(a copy went out inside the cooldown),folded(reported on another delivery's roster). Anullreport — a deliverer that does not report — is "unreported", not "failed", and stamps: that is how every fire before this PR was treated, and a deliverer that knows a send failed says so. The rule is stated as one exclusion rather than an allow-list, so a disposition added later defaults to the quiet side and one meaning "not delivered and owed" has to be added by name.Store seeds memory; memory serves the process. The in-memory dictionaries stay as the fast path but are no longer the authority — the shape #981 gave the email cooldown (
GetLastEmailSentUtcAsyncseeds it across restart). Memory answers whenever it holds anything: 23 of 24 ticks still cost one lookup and no store read, and once seeded, memory may also answer "outside the interval, deliver" without re-asking, because there is one writer per store and it is this process, so the store can never be newer than memory. The store is asked only when memory is empty — a fresh process, or one that has never seen a delivery succeed. That is also why the evaluate half's pre-check and the apply half's own check cost one store read between them, not two.Fail-open, toward delivering, with reasons. A stamp READ that throws falls back to the memory gate, warns naming the document, and is counted into #3013's swallowed-read census under
daily-document delivery-stamp self-alert(one site serves both documents, and the census keys a counted site on a literal name). Fail-open is the direction every store-fault posture in this evaluator already takes for its documents — the rollup's read fault "skips the tick WITHOUT consuming the interval" so an unreadable store cannot become a permanently quiet channel — and it is bounded: memory still gates within the process once one delivery lands, so a store that cannot answer costs at most one extra copy per process, the exact pre-#3580 posture and not a spam path. Failing CLOSED would let a store hiccup silence a daily document, the worse failure. A stamp WRITE that throws leaves the document delivered and the process gated (memory is stamped first), warns that the next restart will re-announce, and is exempt from the census as a write, the resolution-row precedent.What it does NOT do
FireAsync's return is ignored at every one of those sites.collect.collector_statehas existed since V44; two rows of exactly its shape.throttled,folded,mutedorunconfiguredmean — it reads them.null); neither document takes that path (Context: null).config_alert_logalready carriesalert_sent/notification_typeper fire and seeds the cooldowns. Declined because history rows are a RECORD under retention, not state; because "delivered" would then have to be decoded in SQL from channel strings rather than decided at the fire site where the disposition is in hand; and because a stamp is one row, one key, one statement.Load-bearing edits outside the named files
PerformanceMonitor.Alerting/IAlertDeliverer.cs— the reporting twin (default-implemented).PerformanceMonitor.Alerting/AlertReadFailureCounter.cs—FleetScopedReadsnames the new counted read (the Lite.Tests inventory pin derives the set from source and requires the constant to name each).Darling/Darling.Tests/AlertMasterSwitchSurfaceTests.cs—DeliverAndReportAsyncadded to the delivery-seam census regex (the funnel now has 1 site under that name, the census counts stay exact); the FireAsync declaration exclusion allows a genericTask<…>return.Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs— evaluator census 8→9 counted, 11→12 exempt; tree total 32→33 counted, 24→25 exempt, each with its sentence.Lite.Tests/AlertReadFailureSurfaceTests.cs— fleet-scoped read inventory 7→8.Tests
SelfAlertDeliveryStampTests(new, 18 cases — every pin runs over BOTH documents through their apply seams, with a reporting deliverer built on the product's ownAlertDelivery.FromFanoutderivation and an in-memory stamp store shared between two evaluator instances, the "simulated restart"):FleetSweepStore.FleetScopeServerIdand 0, owner name is no collector's and neither other worker-owned name, keys distinct, SQL schema-qualified with the PK conflict target; the worker hands the evaluator the store-backed stamps.SelfAlertDeliveryStampStoreLivePostgresTests(new,DARLING_TEST_PG-gated, own scratch store): both statements parse against a migrated store; round-trip to the tick with Kind Utc; a Kind-Unspecified input reads back Utc; the upsert replaces (exactly two rows under the owner); the row sits atserver_id 0 / self_alertwith theZin the value and a naive-UTCupdated_atwithin a minute of now; a hand-poisoned value reads as no stamp with a warning rather than a throw.Executed locally via the mactest pattern (a throwaway
net10.0xunit project withAssemblyName=Darling.Testsover the built WPF-free assemblies):SelfAlertDeliveryStampTests18/18,FleetSweepRollupTests,CollectorCostDigestTests,DarlingSelfAlertTests,AlertMasterSwitchSurfaceTests,AlertReadFailureSurfaceTests,AlertEngineTests,DarlingAlertingTests,AlertFiringLogTests,FleetSweepEngineTestsand the rest of the compiled set — 643 total, 0 real failures (6CollectionLogStoreProbeAttributionTestscases fail only because that file resolves the repo root from the assembly directory, a harness artifact). The Lite.Tests inventory pin: 6/6. The live round-trip was executed against a realtimescale/timescaledb:latest-pg17container withPgMigrations.MigrateAsyncrun first — 1/1. Every touched project builds-c Release -p:EnableWindowsTargeting=truewith 0 warnings (Service, Alerting, Darling.Tests, Lite, Lite.Tests).Closes #3580