Repository navigation
A chain that fires every Tuesday at the same hour was rated a fresh incident each week and nobody was told: the pass now labels it "recurring at this hour" at unchanged severity, and a weekly Agent job whose slot slid gets "maintenance window moved" (partial #3653 item 3, Q3) - #3725
Merged
Merged
Conversation
Owner
Author
|
Retriggering CI: GitHub dropped the pull_request synchronize event for d0f93a5 (only the pull_request_target check ran); closing and reopening to fire the Build workflow on the current head. No code change. |
erikdarlingdata
force-pushed
the
fix/3653-recurrence-label
branch
from
September 19, 2026 11:58
d0f93a5 to
c3a18e1
Compare
…esh incident each week and nobody was told: the analysis pass now LABELS it "recurring at this hour" at unchanged severity, and a weekly Agent job whose slot slid gets "maintenance window moved" (partial #3653 item 3, ruling Q3) Ruling Q3: label, not discount — a weekly problem is still a problem. A new pure RecurrenceLabeler runs at step 3.8 of both SKUs' passes (after the #3709 fold has finished rewriting frozen StoryText, before the store copies it onto the finding) over ONE store read of the prior three weeks: every chain that fired in this pass's hour×weekday slot on the target's clock (server_properties.utc_offset_minutes, UTC disclosed when absent) plus every RUNNING_JOBS-rooted card in any slot, collapsed to one row per hour. Three consecutive weeks in the slot (this pass counting) appends one sentence to the story's frozen Investigation and stamps metadata; a fired job whose name sat in a different slot last week labels every story tied to the job. Severity is bit-identical with and without the label, pinned. Both get_analysis_findings twins publish recurring_at_this_hour / recurrence_weeks / maintenance_window_moved off the representative's frozen text. Fenced files untouched: AnomalyIncidentReconciler, FactScorer, PgTarget*, BaselineMath, CollectAndScoreFactsAsync on both SKUs.
…er's prior-weeks read as its eighth AcquireReadLock — a tokened READ on the pass, stated there as the decision the pin asks for (partial #3653 item 3)
erikdarlingdata
force-pushed
the
fix/3653-recurrence-label
branch
from
September 19, 2026 12:16
c3a18e1 to
c814df5
Compare
Closed
39 tasks done
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.
Partial #3653 — item 3 (Q3)
The lie
A diagnostic chain that fires every Tuesday at 14:00 — a report, a batch, an index rebuild — was rated by the engine as a fresh incident every week, and nothing on the card, in the e-mail or on the MCP wire said it was the third Tuesday running. #3538 A9 proposed the opposite fix: DISCOUNT the severity of a chain that recurs at the same hour×weekday for three consecutive weeks, because it is "expected". Erik ruled it the other way (Q3, verbatim): "Label, not discount: 'recurring at this hour' at unchanged severity. A weekly problem is still a problem." The sibling half of A9 — a weekly maintenance job whose window MOVES (Thursday 02:00 last week, Tuesday 03:00 this week) — defeats any same-slot rule by construction and defeats the operator the same way: last week's symptom "went away" at the old hour and a new one appeared at the new one.
Mechanism
A new pure class,
PerformanceMonitor.Analysis/RecurrenceLabeler.cs, called as step 3.8 in both SKUs' pass methods (DarlingAnalysisService.AnalyzeAsync, LiteAnalysisService.AnalyzeAsync): afterAnomalyIncidentReconciler.Reconcilehas finished rewriting frozenStoryText(#3709) and BEFOREFilterMutedFindingsAsynccopies it onto the finding — so the label is on the persisted row every card, e-mail and MCP read renders, with no renderer change (the #3709 pattern:TryReadStoryText→with { Investigation = … + sentence }→SerializeForStoryText).One store read per pass, not per story.
PgFindingStore.GetPriorOccurrencesAsync/ LiteFindingStore.GetPriorOccurrencesAsync(new SQL constGetPriorOccurrencesSql, one token apart —server_propertiesvs Lite'sv_server_propertiesview — and pinned to exactly that difference) returns, over the prior 21 days + 1 h of slack onidx_analysis_findings_time:story_path_hash) that fired in the reference instant's hour×weekday slot, andRUNNING_JOBS-rooted card in ANY slot (a job that slid is by definition in a different slot),collapsed to one row per (chain, root key, local hour) so the per-cycle re-persist (
FindingOccurrences: 27.9× mean) counts as one "fired in that hour".story_textis read for the job rows only (MAX(CASE WHEN root_fact_key = 'RUNNING_JOBS' …)), because the frozen job card is the one place a prior week's job NAME survives —Fact.ObjectNameis not a finding-row column and a rung for one is not this lane's. The reference instant iscontext.TimeRangeEnd: "now" for a scheduled pass, the anchor for an as-of pass, and the exclusive upper bound of the read (this pass has not persisted; earlier passes today are week 0 and are dropped).The slot is on the target's clock. Rows and the reference are shifted by the latest
server_properties.utc_offset_minutes(the same readPgFactCollector.QueryPerf/DuckDbFactCollector.QueryPerfmake) beforeEXTRACT(HOUR)/EXTRACT(DOW). Q6 ruled buckets key on the target's local clock; this lane does not build a UTC-only rule for Q6 to invalidate. When the store carries no offset (a PostgreSQL target has noserver_propertiesrow; a SQL Server target's on-load collector may not have run) the read returns a null offset, the slot is UTC, and the sentence SAYS so. One offset across the whole window is a fixed offset, not a zone — a DST change inside the 21 days smears the older week by an hour, which is the Q6/Q8 defect (the zone-id rung), disclosed in the SQL comment, not fixed here.Recurring at this hour. ≥ 3 CONSECUTIVE weeks counting this pass (
{1,2}→ 3;{1}→ 2;{1,3}→ 2, the gap ends the run;{1,2,3}→ 4, the read's ceiling). Appended sentence (fixed phrasing, becauseTryReadLabelparses the count back out of it):(UTC fallback:
— 14:00 Tuesday UTC (the store carries no UTC offset for this server, so the slot is keyed on UTC) —.) Metadata on the story'sRootFactMetadata— a COPY, becauseInferenceEnginehands the story the root fact's own dictionary by reference —recurring_at_this_hour = 1,recurrence_weeks = N,recurrence_local_hour,recurrence_local_dow(0 = Sunday); the stores already copy it onto the in-memory finding for the notification layer.Maintenance window moved. When this pass's fired
RUNNING_JOBSfact (base severity > 0, the #3709 predicate) carries a name, and LAST week's job-rooted card names the SAME job (case-insensitive, read off the frozen headlineAgent job \name`thatFactAdvice.ComposeRunningJobswrites — pinned against the live composer) in a slot that is not this one, every story this pass ties to the job —RUNNING_JOBSon itsPath, or the job linkRUNNING_JOBS fired in the same windowin its frozen Investigation (theLinkedJobClause/ #3709 fold spelling, pinned equal toAnomalyIncidentReconciler.JobSentenceMarker`) — gets:plus
maintenance_window_moved = 1,maintenance_window_last_week_hour,maintenance_window_last_week_dow. Last week only, by design — the sentence compares two weeks. A pre-#3693 card with no name, a different job, a job in the SAME slot (that is recurring, the other arm's case), a quiet job, or a job fact with no name each label nothing rather than guessing.MCP twins.
get_analysis_findingson both SKUs publishesrecurring_at_this_hour(bool),recurrence_weeks(int, null when not labelled) andmaintenance_window_moved(bool) on every entry, read off the representative's frozen text withRecurrenceLabeler.TryReadLabel— the row has no metadata column and a three-week fact cannot be recomputed from a read capped at seven days. One sentence added to both tool descriptions (byte-identical across SKUs, pinned).Evidence
FindingOccurrences, measured 27.9× mean, 39× worst) — hence the per-hour collapse in SQL rather than counting rows.AnalysisFindinghas no object-name column (InsertFindingSql, 21 columns) — hence the name read off the frozen job card, and the composer contract pin.AnalysisRetentionDefaults.FindingsRetentionDays), so the 21-day read is inside it; a shorter retention truncates the stated count honestly.Why this shape
PgTarget*,BaselineMath, orCollectAndScoreFactsAsync.{h,i,r}contract change (a fourth key would be dropped by the nextwith {}re-freeze; the sentence survives one).AnalysisShutdown.IsExpectedAbandon/AnalysisAbandon.IsExpectedare honoured), because a label is presentation and an unreadable history must not cost the pass its findings. This is Lite's one catch-and-degrade read, and its doc says why.What it does NOT do
deprecated/Dashboard(frozen twin; its pass has no labeler).as_of) passes differently: they are labelled relative to the anchor and not persisted, as before.Tests
Lite.Tests/RecurrenceLabelerTests.cs(31, executed on this Mac): 3 weeks → label + slot + read-back; 2 weeks → none; gap week → none; 4 weeks → "4"; severity bit-identical; other chain in slot → none; week 0 ignored; offset puts the slot on the server's clock (03:30Z Tue → 23:00 Monday local); UTC fallback disclosed; idempotent; absolution/zero/legacy skipped; empty/null read no-op; metadata is a copy; moved-window on job/SCH_M/folded-anomaly and NOT the CPU story; same slot → recurring not moved; different/unnamed/quiet/no-facts/two-weeks-ago → none; case-insensitive names;TryReadJobNameagainst the LIVE composer output;JobLinkMarker == AnomalyIncidentReconciler.JobSentenceMarkerand present in a composed SCH_M card;WeeksAgorounding theory; constants. Red-first: a no-op labeler reds 10; aSeverity *= 0.9reds the bit-identical pin.Darling.Tests/RecurrenceLabelStoreReadTests.cs(8; 7 ungated + 1 gated, all executed here against live PG): SQL shape; both stores one statement modulo the view token (from source, and the compiled const equals the source); both pass methods label after the fold and before the mute filter and never inCollectAndScoreFactsAsync; both MCP twins publish the three fields; descriptions byte-identical; live round-trip through to the sentences on both the offset and the no-offset server.Lite.Tests/FindingStoreRecurrenceReadTests.cs(3, DuckDB-backed viaSharedDuckDbFixture): the same read fixture on DuckDB, the UTC fallback, an empty store. CI is first execution for this file (WPF-hosted; the Lite statement was executed here through DuckDB.NET 1.5.5 directly with identical results).DarlingAnalysisStoreTests.AllFindingStoreSqlgains the new const;AnalysisShutdownResidueTestscounts the third pass-side catch inPgFindingStore;RepoFileAdoptionTests.s_lfReadersgains the new pin file.