Skip to content

Maintenance-family anomalies fold onto the fired job's incident and name the job, so a server's own index-maintenance job no longer pages four times without saying its name (Fixes #3704) - #3709

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3704-anomaly-running-jobs-fold
Sep 19, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
fix/3704-anomaly-running-jobs-fold

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

A maintenance window's sub-threshold anomalies now fold onto the incident that carries the fired RUNNING_JOBS, and each one names the job

The lie it stops telling

One dogfood server ran its own nightly index-maintenance Agent job for 116 minutes (a REORGANIZE … LOB_COMPACTION chain, LIMITED physical-stats scans, UPDATE STATISTICS; WRITELOG / HADR_SYNC_COMMIT / PAGEIOLATCH_SH waits). The analysis pass over that window produced four separate ANOMALY_* stories for the server — all one-fact stories, severity 1.5–1.9 — and delivered each as its own page. Not one of them said the job's name. #3538 A9 (PR #3632) had already ruled that a maintenance window's cards are ONE incident naming the job; the ruling did not reach these cards.

Why, at source

#3632 added three edges to RelationshipGraph.BuildMaintenanceEdges — SCH_M, IO_WRITE_LATENCY_MS and WRITELOG onto RUNNING_JOBS, each gated on the job having FIRED (BaseSeverity > 0: at least one job running past its own history; "mere presence would not do"). Those edges are keyed on the regular symptom facts. The anomaly path is separate by design: AnomalyIncidentReconciler ("fold, don't suppress, don't add graph edges" — the Round-2 vetted shape) rewrites an anomaly story's IncidentId onto the regular story whose Path carries the anomaly's mapped family key (ANOMALY_WRITE_LATENCY → IO_WRITE_LATENCY_MS; ANOMALY_WAIT_PROFILE → the family of its dominant contrib_*).

An anomaly is exactly the sub-threshold case: write latency or log flushes elevated against the server's own hour-of-week baseline while the absolute threshold held. So the regular symptom did NOT fire, there was no regular story to fold onto, and nothing in the reconciler or the graph connected the anomaly to the RUNNING_JOBS fact that had fired beside it. Four anomalies, four incidents, none naming the job that caused all of them.

What it does now

A conditional entry in the fold-target set, not a graph edge. When an anomaly's resolved family is one of the three maintenance symptoms (AnomalyIncidentReconciler.MaintenanceFamilies = SCH_M, IO_WRITE_LATENCY_MS, WRITELOG — the edge SOURCES of #3632), RUNNING_JOBS joins its candidate list LAST. The reconciler's index is already keyed on every member of a regular story's Path, so the anomaly folds onto the regular story that consumed the job: the job-rooted story, or a symptom story that reached it through #3632's edge. Its own family stays the first candidate; when the job fired, that story consumed the job through the edge, so both routes land on one incident.

The fired-gate is structural, not re-evaluated. A story carries RUNNING_JOBS in its Path only when the fact scored above zero — InferenceEngine.BuildStories roots at 0.5 or better, the edges' predicate is BaseSeverity > 0, and FactScorer.ScoreAll zeroes the severity of anything with base ≤ 0 — and FactScorer.ScoreJobFact scores the running-long count on a (1, 3) ramp, so ONE job running long is base 0.5: both "fired" and "roots a story". A present-but-quiet job scores 0, drops out of the working set, roots nothing and is consumed by nothing, so no story carries it and the anomalies are exactly as they were. This is the same predicate #3632 uses, seen through the story set instead of the fact set. It also answers the brief's open question: there is no "the job fired but no story carries it" case for the reconciler to mint an incident for, so no incident is minted — the fold onto a real story is the whole shape, and the end-to-end pin below runs the real arithmetic to hold that.

The folded anomaly names the job. The incident's e-mail is led by its highest-severity member and every viewer/MCP card renders its own frozen StoryText; one long job scores 0.5 while a fired anomaly scores 0.5–1.0 and amplifies from there, so the anomaly ordinarily leads and the job card may sit under the notify threshold and never reach the e-mail. Since #3653 (PR #3693) the job's name is on the RUNNING_JOBS fact's Fact.ObjectName, and it reaches the operator only through composed advice frozen into StoryText. So Reconcile gains a second parameter — the run's facts, the same list ClusterIntoIncidents and FactAdvice.PopulateStoryText already take — and, for a maintenance-family anomaly that lands on an incident carrying the job, appends one sentence to the anomaly's frozen Investigation:

RUNNING_JOBS fired in the same window — Agent job Nightly Index Maintenance was running well past its normal duration — so this anomaly and that job are ONE incident, not two: a long index rebuild, CHECKDB or reload drives exactly this write, log-flush and schema-lock pressure, and moving or shortening the job is the fix for both cards.

It reads the blob with FactAdvice.TryReadStoryText and re-freezes with FactAdvice.SerializeForStoryText — the same {h,i,r} shape every card deserializes, so no renderer and no store changes. The sentence follows the incident, not the parent's path: a lone SCH_M story unions onto the job's incident across its own edge without carrying the key, and an SCH_M-dominant wait profile folding onto it by family gets the sentence too. A fact with no name (a job_name that was NULL) gets the unnamed shape pointing at the Running Jobs view; the append is idempotent on its marker.

Non-maintenance anomalies stay solo. CPU spike, memory pressure, blocking/deadlock spikes, read latency, batch/session/query-duration, the db-scoped object pair, and a wait profile dominated by anything but WRITELOG/SCH_M never gain the job as a target and never get the sentence — #3632 declined those edges deliberately (a CPU spike during a rebuild is not the rebuild's card) and this arm honours the same line. Of the live four, only the maintenance-family ones fold; the rest were paging on their own evidence and still do.

Why this shape, and what was rejected

  • Reconciler, not graph. A fourth edge ANOMALY_* → RUNNING_JOBS would put anomaly keys into the traversal and the union-find, which the reconciler's Round-2 header rejects for over-merging; the fold-target set is the one place the anomaly path already decides "whose incident is this", and one more candidate there changes nothing else.
  • Fold onto a story, never mint. The arithmetic above makes the minted-incident arm unreachable; shipping it would be code that no run can execute and no test can honestly reach.
  • Facts passed in, not parsed out of the job story's prose. The name is only ever on the fact; reading it back out of the job card's headline would be regex over prose. The two-argument Reconcile is the load-bearing edit at the one call site per SKU (DarlingAnalysisService.cs, Lite AnalysisService.cs), mirroring how ClusterIntoIncidents(stories, facts) is already called one line above.
  • Sentence into Investigation, headline untouched. The headline is the anomaly's own measurement and FactAdvice's arm (out of this lane); the sentence is the anomaly-side counterpart of LinkedJobClause on the symptom cards, in the same register.
  • A literal MaintenanceFamilies, pinned to the graph. The reconciler is static and graph-less by design; the test asserts every member has a "maintenance" edge onto RUNNING_JOBS in RelationshipGraph.GetAllEdges and that no other family an anomaly can resolve to has one, so the two cannot drift silently.

Blast radius

  • PerformanceMonitor.Analysis is shared by both SKUs and the frozen deprecated/Dashboard. The one-argument Reconcile(stories) is kept and delegates with facts: null: the Dashboard's untouched call site compiles, gets the fold (same rule, structural gate), and writes no sentence — the sentence needs the fact, and the frozen twin is not modernised to pass it. Its AnomalyIncidentReconcilerTests twin is not extended (frozen; no census pins the two files identical).
  • Persisted rows: a maintenance-family anomaly's incident_id now matches the job's, and its story_text Investigation carries one more sentence. Path, hash, severity, confidence and every other column are unchanged; mutes key on the path hash and are unaffected.
  • RelationshipGraph.BuildMaintenanceEdges doc: its "does not name the job" sentence has been false since The analysis said an Agent job was running long and could not say which: the RUNNING_JOBS fact now names the job furthest past its own history on both SKUs, and the card, the frozen finding text and the headline say which one (partial #3653) #3693; corrected to say where the name lives and that the anomaly half is this reconciler arm. Doc-only.
  • No schema change, no rung, no knobs, no FactScorer / FactAdvice / PgTarget* edits, no CHANGELOG edit (entry proposed to the coordinator).

What it does NOT do

Tests

New in Lite.Tests/AnomalyIncidentReconcilerTests.cs (the reconciler's home), all executed on this machine through a throwaway net10.0 xunit runner named Lite.Tests against the built PerformanceMonitor.Analysis.dll — 38/38 in the class, 61/61 with StoryConfidenceTests beside it — and proven red by emptying MaintenanceFamilies (6 failed: the fold, the name, the own-family-then-sentence case, the unnamed/idempotent case, the graph-parity pin and the end-to-end; the three negative pins stayed green as they should):

  • MaintenanceAnomalies_FoldOntoTheFiredJobsIncident_AndTheirTextNamesTheJob — the issue's fixture: four one-fact maintenance-family anomalies (mapped family; dominant WRITELOG; dominant SCH_M; a WRITELOG/SCH_M tie) beside the fired job's story → one id across all four, each Investigation names `Nightly Index Maintenance`, headline/remediation/job story untouched.
  • MaintenanceAnomalies_StayApart_WhenRunningJobsIsPresentButNotFired — the same four with the job present but quiet → four ids, text byte-identical.
  • NonMaintenanceAnomaly_StaysSolo_BesideTheFiredJob (theory, 13 rows) — every other mapped key, the unmapped ones, and wait profiles dominated by PAGEIOLATCH_SH / HADR_SYNC_COMMIT / CXCONSUMER / LCK_M_X / LCK_M_SCH_M.
  • MaintenanceAnomaly_PrefersItsOwnFamilyParent_AndStillNamesTheJob_WhenThatIncidentCarriesIt; MaintenanceAnomaly_FoldsByFamily_WithoutTheSentence_WhenTheIncidentHasNoJob; OneArgumentReconcile_FoldsOntoTheJob_ButCannotNameIt; UnnamedJob_GetsTheUnnamedSentence_AndTheAppendIsIdempotent.
  • MaintenanceFamilies_AreExactlyTheGraphsMaintenanceEdgeSources — the set against RelationshipGraph.GetAllEdges, both directions.
  • EndToEnd_FiredJobRootsAStory_MaintenanceAnomaliesFoldOntoIt_OthersStaySolo_QuietJobChangesNothing — the real FactScorer, InferenceEngine.BuildStories, FactAdvice.PopulateStoryText, ClusterIntoIncidents, IncidentId.StampClusters and the reconciler, in the SKUs' order, over the live window as raw facts (one named long job; write-latency and WRITELOG-dominant wait-profile anomalies at 3σ / 8×; a CPU spike and a query-duration anomaly beside them): five stories, the job at exactly 0.5, three incidents after the fold, the two maintenance anomalies naming the job and the two others not; with the job merely running, four stories, four incidents, no sentence anywhere.

Existing pins: the 17 pre-existing reconciler tests, StoryConfidenceTests (the #3632 edge and cluster pins) and Darling's PgTargetSharedSwitchRoutingTests (one-argument calls) are unchanged and green. No existing test pinned an anomaly's incident id or StoryText beside a fired RUNNING_JOBS.

Builds, zero warnings, -c Release -p:EnableWindowsTargeting=true: PerformanceMonitor.Analysis, PerformanceMonitor.Darling.Analysis, PerformanceMonitorLite, Lite.Tests, Darling.Tests, deprecated/Dashboard.Tests.

Which component(s) does this affect?

  • Lite
  • Darling
  • Lite Tests
  • Darling Tests
  • SQL collection scripts
  • Documentation
  • Full Dashboard (deprecated)
  • CLI Installer (deprecated)

How was this tested?

Executed on macOS against the built shared assembly (above); no SQL Server or store instance is involved — the change is engine wiring over hand-built and scorer-scored facts. CI's build job runs the same tests in Lite.Tests proper.

Checklist

  • I have read the contributing guide
  • My code builds with zero warnings (dotnet build -c Debug)
  • I have tested my changes against at least one SQL Server version (not applicable — no SQL Server surface changed)
  • I have not introduced any hardcoded credentials or server names

Fixes #3704

…ame the job (Fixes #3704)

#3632's three maintenance edges (SCH_M / IO_WRITE_LATENCY_MS / WRITELOG -> RUNNING_JOBS, gated on the
job having FIRED) are keyed on REGULAR symptom facts, and AnomalyIncidentReconciler folded an ANOMALY_*
story only onto a regular story carrying its mapped family. An anomaly is exactly the sub-threshold case
where the regular symptom did not fire, so four one-fact maintenance-family anomalies during one server's
own 116-minute index-maintenance job paged separately and never named the job.

The reconciler now adds RUNNING_JOBS as the LAST fold-target candidate for an anomaly whose resolved
family is one of the three maintenance symptoms (MaintenanceFamilies, pinned to the graph's edge sources),
so it folds onto the regular story that consumed the job. The fired-gate is structural: a story carries
RUNNING_JOBS only when the fact scored above zero, and one long job is base 0.5 on the (1, 3) ramp,
which both fires and roots a story - so there is no fired-but-storyless case to mint an incident for.
Reconcile gains a (stories, facts) overload; both live SKUs pass the run's facts so the folded anomaly's
frozen StoryText Investigation gets one sentence naming the job from the RUNNING_JOBS fact's ObjectName
(#3693). The one-argument form stays for the frozen deprecated/Dashboard twin: it folds, it cannot name.
Non-maintenance anomalies never gain the job as a target.

RelationshipGraph.BuildMaintenanceEdges doc: the "does not name the job" sentence has been false since
#3693; corrected. Pins in Lite.Tests/AnomalyIncidentReconcilerTests: the issue's four-anomaly fixture,
the present-but-quiet job, thirteen non-maintenance keys, own-family-first, the one-argument form, the
unnamed job, idempotence, the graph-parity pin, and an end-to-end run through the real scorer, engine,
clusterer and stamper. Executed here: 38/38, red-first with the set emptied (6 red).
@erikdarlingdata
erikdarlingdata merged commit f52bf53 into dev Sep 19, 2026
6 of 7 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3704-anomaly-running-jobs-fold branch September 19, 2026 09:25
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…le, Opus full tier (#3710) (#3718)

* REVIEW-TRAPS.md: the repository's recurring defect shapes, one line each with the PR that bit (#3710)

Seven layers (SQL, collectors and deltas, analysis, alerting and notifications,
storage and MCP payloads, tests and censuses, workflows and CI, repository
mechanics), fifty-odd lines, every one citing the pull request or issue that
verified it: the >= limit truncation inference (#3594/#3679/#3699), ORDER BY
3 + 4 folding to a constant (#3613), MAX(max_dop) across plans (#3648), integer
division in cntr_value_per_second (#3653 Q9), per-collection alert firing
against a shorter cooldown (#3628/#3636), restart zeros in deltas and baselines
(#3595/#3698/#3705/#3708), ScoreAll skipping amplifiers on a zero base and the
non-virtual GetActiveEdges (#3688), the PgTargetFactKeys censuses (#3688),
pg_total_relation_size vs the heap and the two TimescaleDB views that hide
materializations and history (#3610/#3585), the -infinity crash backoff (#3629),
the Slack block budget and the surrogate-pair cut (#3612/#3618/#3625/#3644),
LCK_M_SCH_M folding into LCK so no SCH_M fact exists (#3709), one wait profile
per run (#3709), and the CRLF round-trip. Each citation was read before it was
written down; a wrong citation is worse than none.

The review workflow reads this file before the PR body and must say, per trap
the diff is near, whether it applies here and why.

* Claude review: blind read before the body, callers rule, structured verdict with a ledger, and an Opus/Sonnet tier by changed path (#3710)

The prompt runs in two phases. Phase 1 forbids gh pr view / git log / git show
until the reviewer has read the diff, every changed region in context, the
callers of every changed public symbol (Grep, both SKUs and both test projects)
and .github/REVIEW-TRAPS.md, and written its own account. Phase 2 reads the
body and grades every claim VERIFIED / UNVERIFIED / REFUTED, then reports the
DIVERGENCE between the two accounts. The verdict is a fixed block -- Claims,
Riskiest lines with the pinning test or UNPINNED, Traps near this diff,
Divergence, Reviewer checklist -- ending in one machine-readable ledger line,
posted through gh pr review --body-file - with a quoted heredoc (no Write tool;
the allowlist is unchanged and still read-only).

The #3650 verdict check now fetches the newest verdict in the window by id and
fails the job when the body lacks the "## Verdict:" header or the ledger, or
when the ledger's verdict disagrees with the review's API state (a
"CHANGES REQUESTED" body submitted with --comment is the shape the guard would
wave through). The guard's own arm is untouched and still keys on the state.

A dorny/paths-filter@v4 step (the repo's pin style) routes changes under the
shared libraries, Darling Storage/Analysis/Service, Lite Services/Mcp/Database,
install/ and every .sql file to the full tier -- claude-opus-5[1m], 60 turns --
and everything else to claude-sonnet-5 at 30 turns; one decision step emits
tier/model/max_turns, one review step consumes them (two steps would double the
"review posted" accounting the guard does), and the prompt carries the tier and
model into the ledger. A filter that dies degrades to FULL, not light.

Step name "Claude review" and the ALWAYS POST sentinel are unchanged: both are
read by claude-review-guard.yml.

* Review tier: Alerting and Notifications read at the full tier (#3710)
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…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.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…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.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…ncident 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)

* A chain that fires every Tuesday at the same hour was rated like a fresh 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.

* The Lite finding store's lock-site census counts the recurrence labeler'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)
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