Skip to content

tempdb Space fired on one collected sample and resolved on the next: the arm now holds for 3 consecutive collections behind the shared persistence gate, counted per collection rather than per sweep, with no-data freezing it instead of resolving to N/A (#3653 A5, tempdb slice) - #3692

Merged
erikdarlingdata merged 1 commit into
devfrom
fix/3653-tempdb-persistence
Sep 19, 2026

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

What does this PR do?

The tempdb Space alert fired on one collected sample and resolved on the next. It now has to hold for three consecutive collections to be an incident — the #3653 A5 persistence-gate rollout, the tempdb Space slice, on the shared AlertPersistenceGate that High CPU adopted in #3282. Partial for #3653 (the A5 line's tempdb arm; the Blocking Wait Time disjunction and the Long-Running Query opt-out are ruling-gated — Q4/Q5 on the issue — and untouched here). References #3653, does not close it.

The lie

AlertEngine.CheckTempDbSpaceAsync read the newest tempdb_stats row and fired the moment ReservedPercent >= TempDbSpaceThresholdPercent, then resolved on the first read under the bar. Measured on one production store class — 43 servers over 14 days — one server fired tempdb Space at or above the 80% bar 22 times, and every run was 1–4 breaching samples spanning at most 206 seconds before the percentage fell back. Twenty-two pages and twenty-two "tempdb Space Resolved" notices in two weeks about a tempdb that was never in trouble for longer than three and a half minutes. That is the exact shape #3282 measured on CPU and fixed with the persistence gate, and AlertPersistenceGate's own header says the built-in checks "adopt the same gate as #3282's own follow-up. One mechanism, not two." This is that follow-up for one arm.

Mechanism

The FIRE decision sits behind the gate; everything downstream is unchanged. CheckTempDbSpaceAsync computes breaching exactly as before, hands it to the gate, and fires only while the record says an incident is open — the threshold compare, the explicit Warning grade (#3660), the cooldown-governed standing-condition reminder, the card, the mute context and the NumericCurrentValue are byte-for-byte what they were. The resolve's falling edge now comes from the gate's Resolve outcome; with a clear count of 1 that is the same instant it always was.

Samples, not sweeps — and on this arm that is the whole fix. The alert sweep runs every 30 s; tempdb_stats lands a row once a minute at the shipped cadence (every five at the light tier). A gate that counted sweeps would reach K = 3 in 90 s of wall clock on ONE collected row — the measured 1–4-sample flap intact behind a gate that looked like it fixed it, which is the trap the CPU arm's own comment names. So TempDbSpaceInfo gains CollectionTimeUtc: the row's collection_time, which both SKUs' reads already ORDER BY … DESC LIMIT 1 — one projected column on a read that already exists, no new read, no new cadence, same one-row index seek (Darling TempDbSpaceSql, Lite LocalDataService.GetLatestTempDbSpaceAsync). The engine counts an observation only when that instant has advanced past the record's LastObservedSampleUtc; a null instant (the deprecated Dashboard's local read, every pre-existing fixture) degrades to counting sweeps — weaker, but the alert still fires, the same direction the CPU gate chose for the same null and for the same reason.

One mechanism, generalised rather than duplicated. The CPU arm's fresh-sample guard, AlertPersistenceGate.Evaluate call and skip-the-write-when-nothing-moved rule are lifted into ObservePersistenceAsync(records, metricName, key, sampleUtc, breaching, breachSamples, clearSamples) → (Outcome, IncidentOpen), and SaveCpuPersistenceAsync becomes SavePersistenceAsync(key, metricName, record). Both arms call them. The CPU arm's behaviour is unchanged — every #3282 pin in AlertEngineTests and BuiltinAlertPersistenceRungTests executes green here (below). A third consumer is a third record dictionary, a third seed line and a third pair of constants, nothing else.

The active flag is gone; the persisted record is the "incident open" bit. _activeTempDbSpaceAlert left the engine for exactly the reasons CPU's did: an in-memory flag re-announced a standing incident on the first post-restart sweep, and a bool beside the record would be a second source of truth for the same fact. The record is seeded at startup under the new metric name "tempdb Space" — a second row in the existing V118 (server_id, metric_name) persistence table, so no migration rung — and an already-open incident gets its cooldown clock stamped at seed, so the reminder waits a full cooldown rather than firing seconds after a restart. Same two-part guard as the CPU seed, same rationale, stated at the site.

No-data freezes the gate. The old arm fell through to its resolve branch on a null tempDb and announced "tempdb reserved space back to N/A" — a recovery nobody measured, rendered with the value it could not read. A null read is now neither a breach nor a clear; the streak and any open incident hold until a real row arrives. The "N/A" arm is deleted with it (the resolve is reached only with a non-null row by construction).

Why K = 3, and why the clear count is 1

TempDbSpaceBreachSamples = 3, derived from the measured flap: every one of the 22 runs was 1–4 samples; K = 3 keeps 2 of 22 — the two runs that held for three or more consecutive collections, which are the only two an operator could still have seen as TRUE when the message arrived. K = 2 would keep the two-sample runs (most of the noise); K = 4 drops both real ones and buys nothing the measurement asked for. Three collections is ~3 min at the one-minute default and ~15 min at the five-minute tier — the constant's comment says so. A constant and not a knob, for CpuBreachSamples' stated reasons (#3314's half-plumbed number is the outcome to avoid).

TempDbSpaceClearSamples = 1, and the comment says why it is NOT the CPU gate's 2: CpuClearSamples is 2 because #3282 measured the shape it guards — 99% then 23% one minute later inside a real saturation event, a mid-incident dip. The #3653 read counted breach RUNS and said nothing about dips inside a run; there is no evidence a tempdb resolve is ever contradicted by the next sample, and reserved tempdb is a slower gauge than scheduler CPU (it moves when allocation units are freed, not per ring-buffer tick). Picking 2 anyway would be the folklore constant the lineage rule refuses. If the fleet shows resolve/re-fire pairs inside one tempdb event, that is the number that moves, with the measurement beside it. Keeping 1 also means the resolve half of this arm is behaviourally unchanged.

Blast radius

  • TempDbSpaceInfo gains a nullable property; the deprecated Dashboard's own TempDbSpaceInfo construction (DatabaseService.NocHealth.cs, frozen twin, no gate) is unaffected — null by default.
  • DarlingAlertReadAdapter.TempDbSpaceSql and Lite's GetLatestTempDbSpaceAsync project one more column. DarlingAlertReadAdapterTests' dialect/table pins on the SQL still hold (no now(, no N', FROM tempdb_stats).
  • AlertReadFailureSurfaceTests (Under store contention the alert pass fails first and silently, so the self-alert that would report it is the first thing lost #3013 census): AlertEngine.cs stays at 14 counted / 6 exempt. No catch block added or removed; the seed's new LoadAlertPersistenceAsync await has its readClock.Restart() before it, and the tempdb try body restarts the clock after the gate call. The one exemption key moved with the generalised save message ("Could not persist the {Metric} persistence gate"), so the census matches the one catch block that now serves both metrics.
  • BuiltinAlertPersistenceRungTests' text pins on the CPU seed (if (cpuPersistence.Value.State.Firing), _lastCpuAlert[key] = _utcNow();) are untouched — the CPU seed block is byte-identical.
  • Two Lite.Tests pins (LiteAlertForwardingTests.TempDb_ToastBody_MatchesTheOldLoop, TempDb_TheAzureCeiling_…) drove one sweep and asserted a fire; they now drive TempDbSpaceBreachSamples sweeps (their fixtures carry no instant, so they ride the pinned every-sweep-counts fallback). Assertions unchanged.
  • Both READMEs' alert tables state the gate; the root README's TempDB row is pinned to the constants (TheReadmeAlertCatalogStatesTheTempDbSampleCountItActuallyUses), the same pin the High CPU row already carries.

What it does NOT do

  • Does not close Brains-review campaign: deferred structural residue (from #3538 / #3539 / #3540 / #3541) #3653 — the A5 line stays unticked; tempdb is one of its three arms.
  • Blocking Wait Time (duration-OR-consecutive disjunction) and Long-Running Query (opt-out coverage) are NOT touched: the blocking arm needs a single-snapshot bar nobody has measured and the LRQ arm needs a new knob — both are rulings (Q4/Q5) on the issue, not worker calls.
  • No migration rung, no StorageVersion bump, no new knob, no CHANGELOG.md edit (entry handed to the coordinator).
  • Does not change the tempdb threshold, the Warning grade, the reminder cadence, the card, or the resolve count (1, as today).
  • Does not widen IAlertReadAdapter; the sample instant rides on the existing return type.

Test plan

Every touched project builds -c Release -p:EnableWindowsTargeting=true with 0 warnings from this change (PerformanceMonitor.Alerting, PerformanceMonitor.Darling.Service, Darling.Tests, PerformanceMonitorLite, Lite.Tests — the latter's one warning is pre-existing on dev in LogTailOverlapThresholdPinTests.cs:95, untouched).

The Darling.Tests engine pins were EXECUTED on this machine, not just compiled, through a throwaway net10.0 xunit harness outside the worktree (AssemblyName=Darling.Tests, referencing the built PerformanceMonitor.Alerting / Common / Notifications / Darling.Storage, compiling AlertEngineTests.cs, AlertPersistenceGateTests.cs, AlertReadFailureSurfaceTests.cs, BuiltinAlertPersistenceRungTests.cs, RepoFileAdoptionTests.cs, DocCommentHygieneTests.cs + helpers): 273 tests, 0 failed — the whole of AlertEngineTests (145, every #3282 CPU pin included), the gate's own 12, the #3013 census's 27 with the moved exemption key, the rung file's 10 (one Viewer-reflecting test excluded, it needs WPF), the LF-reader roster, and the cref/summary hygiene sweep over the tree.

Darling.Tests/AlertEngineTests, tempdb section — new (DriveTempDbAsync helper, the DriveCpuAsync twin):

  • TempDb_OneOrTwoBreachingSamples_DoNotFire_TheThirdFiresExactlyOnce — K−1 → nothing; K → one fire; the cooldown reminder unchanged.
  • TempDb_AStreakBrokenByOneClearSample_NeverFires_UntilThreeRunConsecutively — breach/clear/breach/breach → nothing; the fourth breach fires (the reset is a reset).
  • TempDb_RepeatedCollectionTime_DoesNotAdvanceTheStreak — the same collection_time offered 3K times → nothing; two fresh rows then fire. The pin that matters most on this arm.
  • TempDb_NoCollectionTime_CountsEverySweep — the documented fallback.
  • TempDb_AMissingRowFreezesTheGate_AndNeverAnnouncesARecovery — null reads after a fire: no resolve, no re-announce; one real clear row resolves with the measured number.
  • TempDb_PersistsTheStreakUnderItsOwnMetric_AndResumesAcrossARestart — the record is under "tempdb Space" with the streak and the instant; a new engine over the same store completes it.
  • TempDb_ARestartDoesNotReAnnounceAnAlreadyOpenIncident — restart with Firing = true: no re-announce; the stamped clock is a cooldown, not a silence; resolves normally.
  • TempDb_TheGateAdvancesUnderSuppression_SoOneUnsuppressedSampleDelivers.
  • TempDb_APersistenceSaveFailure_StillFires_AndIsNotCountedAsASwallowedRead — through the shared save path.
  • TempDbGateDefaults_AreDerivedFromTheMeasuredFlap — 3 / 1 / "tempdb Space".
  • TheReadmeAlertCatalogStatesTheTempDbSampleCountItActuallyUses.

Re-shaped to drive the gate (assertions unchanged): TempDb_FiresAtThreshold_AndResolutionCarriesTheCurrentPercent, TempDb_TheAzureCeiling_SuppressesTheAlert_ThatTheAllocationWouldFire, TempDb_FiresAnExplicitWarning_AndNeverGradesCritical.

Lite.Tests/LiteAlertForwardingTests — the two tempdb pins drive K sweeps; compile-verified here, first executed in CI (their harness is the WPF App's statics, which this Mac cannot host). They are the same shape as TempDb_NoCollectionTime_CountsEverySweep, which executes green.

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?

See the test plan: executed here via the harness (273/0), compile-verified on every touched project, CI is the first execution of the Lite.Tests pins. No SQL Server change — the collector and its tempdb_stats row are untouched; the change is read-side and engine-side.

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 (engine-level; the collected row shape is unchanged)
  • I have not introduced any hardcoded credentials or server names

…the arm now sits behind the shared AlertPersistenceGate at K = 3 consecutive collections (#3653 A5, tempdb slice)

The tempdb Space alert fired on the FIRST breaching read of the latest
tempdb_stats row and resolved on the first sub-threshold read after it.
Measured on one production store class (43 servers, 14 days), one
server fired 22 times and every run was 1-4 breaching samples spanning
at most 206 s: 22 pages and 22 resolves about a tempdb that was never
in trouble for longer than three and a half minutes.

The fire decision now sits behind the shared AlertPersistenceGate
(#3282) at TempDbSpaceBreachSamples = 3 consecutive breaching
COLLECTIONS, which keeps 2 of the 22 measured runs - the two an
operator could still have seen as true when the page arrived.
TempDbSpaceClearSamples = 1 keeps the resolve exactly where it was; the
measurement covered breach runs and said nothing about mid-incident
dips, so a larger clear count would be a number without evidence.

Samples, not sweeps: TempDbSpaceInfo carries the row's collection_time
(one projected column on a read both SKUs already ORDER BY it) so a
30 s sweep re-reading the last collected row does not advance the
streak - without that, K = 3 is 90 s of one sample and the measured
flap comes straight back behind the gate. A null instant degrades to
counting sweeps, the same fallback the CPU gate documents.

The CPU arm's fresh-sample guard, evaluate and save-on-change are
lifted into ObservePersistenceAsync and the save into
SavePersistenceAsync(key, metric, record), used by both arms; the CPU
arm's behaviour is unchanged and every #3282 pin stays green. The
in-memory _activeTempDbSpaceAlert flag is gone - the persisted Firing
bit is the one "incident open" fact, seeded at startup under the new
"tempdb Space" metric name in the existing (server_id, metric_name)
persistence table (no rung) with the cooldown stamped on an already-open
incident so a restart does not re-announce it. A null tempdb read now
FREEZES the gate instead of resolving with "N/A".

Blocking Wait Time and Long-Running Query are not touched: both await
rulings (Q4/Q5 on #3653).

Partial for #3653; does not close it.
@erikdarlingdata
erikdarlingdata merged commit 7e216ad into dev Sep 19, 2026
7 of 8 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3653-tempdb-persistence branch September 19, 2026 04:56
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
… snapshot at 3x the bar still fires at once, anything less holds for 3 consecutive collections through the shared AlertPersistenceGate (#3653 A5, Q4)

The Blocking Wait Time alert fired on the FIRST fresh blocking snapshot
whose summed wait_time_ms reached the configured bar and resolved on the
first one under it. Measured on one production store class (43 servers):
102 episodes, 97 of them a SINGLE snapshot at the collector's ~72 s
cadence - including the p99, 1,509 s of summed blocked wait across 145
sessions, gone by the next collection - so a plain K-consecutive gate
(the tempdb shape from #3692) would have dropped exactly the episodes an
operator most needs to hear about.

Erik ruled Q4 on #3653: fire on a single snapshot at N = 3x the
configured threshold; otherwise K = 3 consecutive collections through
the shared AlertPersistenceGate. Both arms ride ONE gate: the bar handed
to ObservePersistenceAsync is one sample when the snapshot alone clears
the multiple and BlockingWaitBreachSamples otherwise, so a 3x fire opens
the incident on the same persisted record the consecutive arm would
have opened it on and the clear that follows is the same clear. The fire
prepends a gate item whose `Fired By` field carries the arm token
(single_snapshot_3x / consecutive_k3) so the card, the history row and
get_alert_history's context_json can say why.

What makes this arm unlike tempdb's: dmv_blocking_snapshot writes rows
only while something is blocked, so a quiet cycle writes nothing and
"the latest snapshot" simply stops advancing. Two breaching snapshots
with a quiet cycle between them are two episodes, so the arm restarts a
partial streak when a breaching snapshot lands more than 1.5 collector
cadences after the last one counted (CurrentBlockingWaitResult now
carries the cadence both adapters already resolved for the freshness
verdict). An OPEN incident is not restarted by a short gap. A STALE
snapshot is a clear here (the #1812 rule this arm always had - it is the
collector saying the blocking ended), observed per sweep; a NULL read
freezes the gate (the #3692 rule); disabling the gate is not a clear
(the CPU arm's rule). The in-memory _activeBlockingWaitAlert flag is
gone; the persisted Firing bit under the alert's own metric name in the
existing (server_id, metric_name) table is the one "incident open" fact
(no rung), seeded at startup with the cooldown stamped so a restart does
not re-announce.

Lite and Darling both route this alert through the shared engine, so
both SKUs get the gate. Long-Running Query (Q5) is a separate commit.

Partial #3653 - item 6 (Q4). Does not close it.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
… snapshot at 3x the bar still fires at once, anything less holds for 3 consecutive collections through the shared AlertPersistenceGate (#3653 A5, Q4)

The Blocking Wait Time alert fired on the FIRST fresh blocking snapshot
whose summed wait_time_ms reached the configured bar and resolved on the
first one under it. Measured on one production store class (43 servers):
102 episodes, 97 of them a SINGLE snapshot at the collector's ~72 s
cadence - including the p99, 1,509 s of summed blocked wait across 145
sessions, gone by the next collection - so a plain K-consecutive gate
(the tempdb shape from #3692) would have dropped exactly the episodes an
operator most needs to hear about.

Erik ruled Q4 on #3653: fire on a single snapshot at N = 3x the
configured threshold; otherwise K = 3 consecutive collections through
the shared AlertPersistenceGate. Both arms ride ONE gate: the bar handed
to ObservePersistenceAsync is one sample when the snapshot alone clears
the multiple and BlockingWaitBreachSamples otherwise, so a 3x fire opens
the incident on the same persisted record the consecutive arm would
have opened it on and the clear that follows is the same clear. The fire
prepends a gate item whose `Fired By` field carries the arm token
(single_snapshot_3x / consecutive_k3) so the card, the history row and
get_alert_history's context_json can say why.

What makes this arm unlike tempdb's: dmv_blocking_snapshot writes rows
only while something is blocked, so a quiet cycle writes nothing and
"the latest snapshot" simply stops advancing. Two breaching snapshots
with a quiet cycle between them are two episodes, so the arm restarts a
partial streak when a breaching snapshot lands more than 1.5 collector
cadences after the last one counted (CurrentBlockingWaitResult now
carries the cadence both adapters already resolved for the freshness
verdict). An OPEN incident is not restarted by a short gap. A STALE
snapshot is a clear here (the #1812 rule this arm always had - it is the
collector saying the blocking ended), observed per sweep; a NULL read
freezes the gate (the #3692 rule); disabling the gate is not a clear
(the CPU arm's rule). The in-memory _activeBlockingWaitAlert flag is
gone; the persisted Firing bit under the alert's own metric name in the
existing (server_id, metric_name) table is the one "incident open" fact
(no rung), seeded at startup with the cooldown stamped so a restart does
not re-announce.

Lite and Darling both route this alert through the shared engine, so
both SKUs get the gate. Long-Running Query (Q5) is a separate commit.

Partial #3653 - item 6 (Q4). Does not close it.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
…utive collections, not on every snapshot; Long-Running Query gets an opt-out knob seeded from the production read — job-step program prefix, the two NT AUTHORITY logins — applied ahead of the row cap and counted by arm (#3653 A5, Q4 + Q5) (#3734)

* Blocking Wait Time fired on one snapshot and cleared on the next: one snapshot at 3x the bar still fires at once, anything less holds for 3 consecutive collections through the shared AlertPersistenceGate (#3653 A5, Q4)

The Blocking Wait Time alert fired on the FIRST fresh blocking snapshot
whose summed wait_time_ms reached the configured bar and resolved on the
first one under it. Measured on one production store class (43 servers):
102 episodes, 97 of them a SINGLE snapshot at the collector's ~72 s
cadence - including the p99, 1,509 s of summed blocked wait across 145
sessions, gone by the next collection - so a plain K-consecutive gate
(the tempdb shape from #3692) would have dropped exactly the episodes an
operator most needs to hear about.

Erik ruled Q4 on #3653: fire on a single snapshot at N = 3x the
configured threshold; otherwise K = 3 consecutive collections through
the shared AlertPersistenceGate. Both arms ride ONE gate: the bar handed
to ObservePersistenceAsync is one sample when the snapshot alone clears
the multiple and BlockingWaitBreachSamples otherwise, so a 3x fire opens
the incident on the same persisted record the consecutive arm would
have opened it on and the clear that follows is the same clear. The fire
prepends a gate item whose `Fired By` field carries the arm token
(single_snapshot_3x / consecutive_k3) so the card, the history row and
get_alert_history's context_json can say why.

What makes this arm unlike tempdb's: dmv_blocking_snapshot writes rows
only while something is blocked, so a quiet cycle writes nothing and
"the latest snapshot" simply stops advancing. Two breaching snapshots
with a quiet cycle between them are two episodes, so the arm restarts a
partial streak when a breaching snapshot lands more than 1.5 collector
cadences after the last one counted (CurrentBlockingWaitResult now
carries the cadence both adapters already resolved for the freshness
verdict). An OPEN incident is not restarted by a short gap. A STALE
snapshot is a clear here (the #1812 rule this arm always had - it is the
collector saying the blocking ended), observed per sweep; a NULL read
freezes the gate (the #3692 rule); disabling the gate is not a clear
(the CPU arm's rule). The in-memory _activeBlockingWaitAlert flag is
gone; the persisted Firing bit under the alert's own metric name in the
existing (server_id, metric_name) table is the one "incident open" fact
(no rung), seeded at startup with the cooldown stamped so a restart does
not re-announce.

Lite and Darling both route this alert through the shared engine, so
both SKUs get the gate. Long-Running Query (Q5) is a separate commit.

Partial #3653 - item 6 (Q4). Does not close it.

* Long-Running Query gets an opt-out knob by program_name / login, applied in the read ahead of the row cap, and the card counts what it removed (#3653 A5, Q5) - engine + Lite; Darling's store home is a rung away

Measured on one production store class: 191 distinct sessions over the
30-minute Long-Running Query bar in 7 days, the p90 of them seen in
6,192 snapshots. These are permanent background requests - brokers,
replication readers, agent loops, pollers - that are over any duration
threshold forever. No persistence gate can help (they persist by
definition); coverage is the fix, and which programs and logins are the
background on an estate is per-operator knowledge, so it is a knob.
Erik ruled Q5 on #3653: the knob is allowed; the "what are they"
measurement that would seed it is the monitor seat's read.

IAlertEngineSettings gains LongRunningQueryExcludedProgramNames and
LongRunningQueryExcludedLogins. The rule is spelled ONCE in the new
LongRunningQueryExclusions (Alerting): case-insensitive, whole-value, a
trailing * matches any suffix, % and _ literal, null matches nothing, a
bare * refused; From/Normalize give a Settings-window string and an MCP
array one meaning. An excluded session is NOT EVALUATED - not read into
the decision, not counted, not fingerprinted - which is the opposite of
a mute rule, and is why excluding a paging program resolves the open
incident instead of silencing it.

Applied IN THE READ, before LIMIT, on both SKUs. The read is longest-
elapsed-first capped at maxResults (5), and the sessions this knob is
for are the longest-running on the server by construction, so a post-
read drop (the excludedDatabases shape) would let five permanent
requests fill the cap on every sweep and hide the real query behind
them - the alert made blind by the knob meant to make it accurate. Both
reads gain a candidates CTE that flags each over-threshold row with the
shared ILIKE ... ESCAPE predicate (BuildSqlPredicate, operands from $5),
keep the unflagged rows under the cap, and return the flagged count as
an uncorrelated scalar over the same CTE: one statement, one snapshot.
IAlertReadAdapter.GetLongRunningQueriesAsync returns
LongRunningQueryReadResult(Sessions, ExcludedCount); login_name is now
projected. The High CPU card's maintenance probe passes None: a backup
run by an excluded service login is still what is burning the CPU.

The card appends an "Excluded Count" item - only when the knob is set -
so an operator can see the knob working; a setting whose only effect is
a page NOT arriving is one nobody can verify.

Lite: two settings.json arrays (documented in settings.sample.json),
two boxes on the Settings window's Alerts tab, AppAlertEngineSettings
forwards them. Darling: DarlingAlertSettings returns EMPTY for both,
with the reason in the comment - the store row is authoritative for
every alert knob (ApplyToConfig replaces config.Alerts wholesale), so a
darling.json-only member would read as configured and be dead; the
honest home is two text[] columns on config_alert_settings, which is a
migration rung this lane may not write (cross-seat fence). The MCP
twins publish the knob on neither SKU yet for the same reason
(McpAlertSettingsKeyTests pins the two payloads to one shape). The
Darling store plumbing follows as its own commit, gated on that rung.

Partial #3653 - item 6 (Q5). Does not close it.

* The Long-Running Query opt-out knob is seeded from the production read: program names match by PREFIX, logins exactly, the two lists default to the SQL Agent job-step prefix and the two NT AUTHORITY service logins, and the card counts what each arm removed in sessions (#3653 A5, Q5 addendum)

The 7-day read of one large production store (monitor seat, authorised
under Q5) split the long-running population into four classes: (1) SQL
Agent job-step programs - program_name prefix "SQLAgent - TSQL JobStep",
~460 sessions/week across 11 single-server jobs, medians 35-62 min;
(2) the NT AUTHORITY\SYSTEM / NT AUTHORITY\NETWORK SERVICE logins - the
permanent multi-DAY background (~70 sessions across 42 servers, medians
4.8-8.6 days, 300K+ snapshots, CDC-capture shaped); (3) the application's
admin login - carries the job wave but ALSO real ad-hoc long-runners, so
excluding it would blind the alert to what it is for; (4) named humans -
3 sessions/week, the page's whole purpose. That read changes the knob's
shape from the previous commit:

- Program names match by PREFIX (case-insensitive), because job-step
  names embed the job id; logins match EXACTLY (a prefix would let svc
  swallow svc_owner). The trailing-* grammar is gone: an entry is the
  literal prefix / value, % and _ escaped before LIKE, * a character.
  The member is LongRunningQueryExcludedProgramNamePrefixes (settings.json
  alert_long_running_query_excluded_program_name_prefixes) so the name
  says the rule.
- SEEDED DEFAULTS: LongRunningQueryExclusions.DefaultProgramNamePrefixes
  = ["SQLAgent - TSQL JobStep"], DefaultLogins = ["NT AUTHORITY\SYSTEM",
  "NT AUTHORITY\NETWORK SERVICE"]. Lite: the App statics initialise to
  the seeds and the settings.json reader replaces a list only when its
  key is PRESENT - so a fresh install / a file without the key evaluates
  with the seeds, and an operator who empties a box (the window always
  writes both keys) has cleared that arm for good; Restore Defaults puts
  the seeds back, not empty boxes. Darling: DarlingAlertSettings returns
  the seeds - not an empty knob - until the V135 rung gives the knob its
  store home, so both SKUs evaluate the same population from day one.
- excluded_count SPLITS: both reads return excluded_by_program_prefix and
  excluded_by_login as two scalars over the candidates CTE, counted in
  SESSIONS (COUNT(DISTINCT session_id) - inside one collection of one
  server the (server_id, session_id, tran_start_time) identity collapses
  to session_id; a MARS session with two request rows is one session),
  the login count taken AND NOT the program flag so a session matching
  both arms counts once, under the prefix. The card's knob item shows
  Excluded Count, Excluded By Program Prefix, Excluded By Login and the
  entries.
- Docs: IAlertEngineSettings, the Lite Settings window, settings.sample
  .json, README.md and Darling/README.md each state the four classes, the
  two seeds with their grounding, and why the admin login is absent.
- Lite's Settings boxes are AlertLrqExcluded*Box so the #1840 wiring
  census sees them; they are gated on the master switch and reset to the
  seeds. The census's collection exception names the two new statics.

Executed: the Darling read against a live PostgreSQL 18 store (the E2E's
four-class snapshot: prefix arm 2, login arm 1 despite two rows, both
arms once; mutation to COUNT(*) without AND NOT fails 3 != 1); the exact
Lite DuckDB SQL text on DuckDB 1.5.5 (same fixture, plus the %/_ escaping
and the literal-* cases); 347 Darling.Tests in the mactest harness.

Partial #3653 - item 6 (Q5). Does not close it.
erikdarlingdata added a commit that referenced this pull request Sep 19, 2026
… PRs across the #3653, #3542 and #3691 waves, #3704, #3710, #3712 and #3735, spliced once (#3751)

Sixty-five PRs merged to dev since #3672 (the first splice) across two seats -
the coordinator's #3653 brains-review residue lanes and the wave-2 worker's
#3542 / #3691 PostgreSQL-target engine lanes - and by rule none touched this
file; each returned its entry to a buffer. This is the one splice. Text-only;
one file; nothing outside ## [Unreleased] and the definition block moves.

[Unreleased] gains a ### Changed between ### Added and ### Fixed (Keep-a-Changelog
order) for the nine entries whose subject is a designed behaviour that now behaves
differently - routing (#3712 confidence chooses the channel), gating (#3692 tempdb
persistence, #3734 blocking/LRQ gates, #3724 peak-and-mean), the wire shape
(#3719 one error envelope), the review guard's drift arm (#3673), lineage
metadata (#3723) and two grading changes (#3693, #3747). Twenty-two new
capabilities append to ### Added (the sixteen PostgreSQL-target fact families and
rungs, CONFIG_CHANGED, the recurrence label, the V134/V135 columns, the review
traps file, the measurement census); thirty-two defects append to ### Fixed.
Order within each section is ascending PR number, as #3672 did.

Every entry is one line and verbatim from its buffer (trailing whitespace only).
Four PR pairs fold into one entry as the buffers wrote them (#3738+#3746,
#3683+#3686, #3681+#3685, #3726+#3729); #3695 carries three entries. #3673 has
no issue and cites none, like the file's ninety-six other ref-less entries. Five
definitions added ([#3691], [#3704], [#3710], [#3712], [#3735]) at the end of the
[Unreleased] run, which stays one ascending run (3517..3735). No archive touched;
the split was not run.

tools/changelog/changelog_archive.py verify: all PASS (1,483 bold entries, floor
1,329; 1,363 distinct refs resolve; 1,372 definitions; CRLF throughout; 409,263
bytes under the ceiling). Client-hygiene grep over the file: zero hits.
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