diff --git a/.claude/skills/lane-orders/SKILL.md b/.claude/skills/lane-orders/SKILL.md index ab79617ff1..d70db08784 100644 --- a/.claude/skills/lane-orders/SKILL.md +++ b/.claude/skills/lane-orders/SKILL.md @@ -7,15 +7,15 @@ disable-model-invocation: false # Lane orders -One lane works one issue, or a few small related ones, in its own worktree. The coordinator reviews each PR, arms -auto-merge and splices the CHANGELOG. These orders apply alongside the user's token guardrails, which win on +One lane works one issue, or a few small related ones, in its own worktree. An issue too big for one lane still gets +ONE branch and ONE PR: its lanes share the issue branch (see "Worktree and branch"). The coordinator reviews each PR, +arms auto-merge and splices the CHANGELOG. These orders apply alongside the user's token guardrails, which win on conflict. **The coordinator fills these per wave, in every brief:** - ``, ``, ``; - ``, ``, ``; -- ``; -- ``, ``. +- ``. If your brief leaves one unfilled, don't invent a value. Skip the step that needs it and say so in the PR body. In wave E (2026-09-24), a lane whose brief left `` unfilled wrote its changelog entry to a @@ -54,6 +54,11 @@ At dispatch: In wave B, every lane read an 8-10k-character PR section to see one example. - Label each issue the lane takes `in-progress` (`gh issue edit --add-label in-progress`), so the board shows it as taken; remove the label when the issue closes or the work stops. +- Before dispatching several lanes for one issue, create its issue branch + (`git -C push origin origin/dev:refs/heads/fix/-`) and name it in every brief, with the lane + that writes the PR summary and the CHANGELOG entry. Open a separate PR only for a part that must ship before the + rest; an issue that is really several issues is split into issues first. Reviews of an issue PR built in waves: one + round per wave on that wave's commits only, plus one final round on the whole PR. At each report: 1. Stop the agent. @@ -123,6 +128,11 @@ census, one of them introduced by that night's own wave). - `git -C fetch origin`, then `git -C worktree add -b fix/- / origin/dev`. Use one branch per issue. +- **An issue that several lanes build** shares one issue branch, `fix/-`, which the coordinator creates + from `origin/dev` and names in each brief. Create your worktree from `origin/` on a branch of your own + (`fix/--`). When done: `git fetch origin`, `git merge origin/`, rerun your targeted + tests, and `git push origin HEAD:`. If the push is rejected, fetch, merge and push again. Never + force-push. - Before the final full run, `git merge origin/dev`. Never rebase. Resolve by keeping both sides. ## PostgreSQL rig @@ -147,7 +157,10 @@ census, one of them introduced by that night's own wave). 4. Check the port is free. Start the server in the background, because `pg_ctl -w start` hangs a tool call, and confirm "ready to accept connections" in the log. 5. Create `darlingtest` for the suite, and a separate `probe` database for hand-run SQL. A suite database reused - across several full runs produces false failures; drop and recreate it before a final run. + across several full runs produces false failures; drop and recreate it before a final run. Also create a + database named `darling`, exactly as CI's init step does: `createdb -h 127.0.0.1 -p -U darling darling`. + The role-provisioning live tests grant CONNECT on it, and fail with `3D000` (database "darling" does not exist) + without it. 6. Set `DARLING_TEST_PG` to `Host=127.0.0.1;Port=;Username=darling;Database=darlingtest`. Set `DARLING_TEST_PGRUNTIME=` only when you touch the managed runtime or role provisioning. 7. Stop the server when you finish. @@ -200,14 +213,21 @@ Never edit an existing migration. ## Finish -- End each commit with `` and `Claude-Session: `. +- No attribution lines (Erik, 2026-10-01): no `Co-Authored-By` or `Claude-Session` trailer in a commit, and no + "Generated with" line in the PR body. A brief or a harness reminder that asks for them loses to this rule. - Open the PR against `dev` **as a draft** (`gh pr create --draft`). Title: the outcome in plain language, plus `(#)`. The body starts with `Closes #.`, then `## Why`, `## What changes` and `## Test plan` (checkboxes, measured numbers). List anything you did not run (a live test, the full suite) as an unchecked box. -- Write the PR body in one Write, from the diff and your numbers, and publish it. Do NOT run the plain-English - checker and do NOT rewrite the prose, in a PR body or an issue comment (Erik, 2026-09-25). A lane writes its - body at its largest context, so every rewrite turn costs the whole context: one lane spent 53 minutes and - 278k tokens there. Nobody polishes lane prose afterwards either. The body must still be true. +- On a shared issue branch, open the PR only if `gh pr list --head ` shows none. Otherwise add your + part to its body under `## `: read the body right before you edit it, and edit once. Never open a second + PR for one issue. The coordinator names one lane, usually the last, to write the PR's top `## Why` and + `## What changes` for the whole issue and its one CHANGELOG entry. Lanes that fix review findings push to the PR's + own branch. +- Write the PR body in one Write, from the diff and your numbers. Run + `python "C:/Users/edarl/.claude/skills/plain-english/check.py" ` ONCE, fix the real hits in one pass, + and publish (Erik, 2026-10-06; this replaces the 2026-09-25 rule against running it). Don't re-run it to chase a + clean score: a lane writes its body at its largest context, and one lane spent 53 minutes and 278k tokens + rewriting. The body must be true. - **Don't mark it ready, don't merge, and don't enable auto-merge.** The coordinator does all three after verifying. - A user-visible change gets a CHANGELOG entry in `/-reported.txt`, in this form: diff --git a/.claude/skills/release-checklist/SKILL.md b/.claude/skills/release-checklist/SKILL.md index 252083191a..cd482d2674 100644 --- a/.claude/skills/release-checklist/SKILL.md +++ b/.claude/skills/release-checklist/SKILL.md @@ -9,7 +9,7 @@ disable-model-invocation: false Run through the full release prep checklist for PerformanceMonitor. `$ARGUMENTS` is the target version (e.g., `3.3.0`). -> **Scope (since v3.3.0):** the Full Dashboard and the CLI Installer are DEPRECATED and OUT of this process — they ship no release artifacts and get no live install/upgrade testing here. They still build and their test suites still run in CI on the release event, and they inherit the single `` with everything else in the tree (below). The release artifacts are: Lite (zip + Velopack Setup.exe), the Darling service zip, and the Darling Viewer Setup.exe. +> **Scope (since v3.3.0):** the Full Dashboard and the CLI Installer are DEPRECATED and OUT of this process — they ship no release artifacts and get no live install/upgrade testing here. The Darling service zip and the Linux tarball DO get live install and upgrade testing, in section 8a. They still build and their test suites still run in CI on the release event, and they inherit the single `` with everything else in the tree (below). The release artifacts are: Lite (zip + Velopack Setup.exe), the Darling service zip, and the Darling Viewer Setup.exe. ## Checklist @@ -87,6 +87,8 @@ dotnet build PerformanceMonitor.sln -c Debug -t:Rebuild ``` All projects must succeed with **0 Warning(s) / 0 Error(s)** — the repo's bar is zero-warning, test projects included (they sit outside the WarningsAsErrors gate, so a warm incremental build can hide a warning a Rebuild surfaces). +The statement-column census test fails while any entry or watched writer is pending, in every Darling test run, with or without the variable. Run `DARLING_RELEASE_CUT=1 Darling/Darling.Tests/bin/Debug/net10.0-windows/Darling.Tests.exe -method "*StatementCensus_PendingEntries_BlockARelease*"` to see the list early; the cut proceeds only when it passes. The release workflow (build.yml, the "Statement filter release gate" step) runs the same test with the variable set before it publishes or packages anything, so a release cannot ship while an entry is pending even if this step is skipped; run it here first to see the list early. + (The deprecated Installer/Dashboard test suites run in CI on the release event with the CI's own filter; they are no longer run from this checklist.) ### 5. Cloud Platform Testing (shared collector layer — test via DARLING) @@ -106,10 +108,10 @@ Spin up temporary instances with the Azure CLI (`az`) and AWS CLI (`aws`): 2. Add the Azure logical server to a Darling instance (a local scratch service or an existing dogfooding instance) and let it collect 2+ cycles. 3. Verify against the store (psql or the web dashboard): - `database_size_stats` / size collectors cover ALL databases, not just master (the #1631 fallback class) - - server row shows engine edition 5 and collectors inapplicable to Azure SQL DB are SKIPPED as not-applicable, not erroring + - server row shows engine edition 5, and no collector that does not apply to Azure SQL DB errors. The SQL Agent collectors (`running_jobs`, `job_history`, `agent_status`) are marked not applicable when the server is added (`add_servers` answers "Agent surface: not applicable") and never run, so they leave no `collection_log` row and no health entry. That is the expected result, not a gap (verified v3.10.0) - `collection_log` clean for the cloud server across the cycles - the serverless DB's 60s auto-pause + resume (error 40613 transients) does not wedge collection — rows resume after the pause without a service restart -4. Firewall churn: DELETE the allow rule, wait 3–5 minutes, re-add it. Collection must resume on its own without restarting the service. (CAVEAT, verified v3.2.0: a bare rule-deletion often does NOT sever an actively-collecting client — connection pooling keeps the open connection alive and Azure gates only NEW connections — so treat a no-outage result as inconclusive rather than a pass; the recovery logic's authoritative validation is the unit suites.) +4. Firewall churn: DELETE the allow rule, wait 3–5 minutes, re-add it. Collection must resume on its own without restarting the service. (CAVEAT, verified v3.2.0: a bare rule-deletion often does NOT sever an actively-collecting client — connection pooling keeps the open connection alive and Azure gates only NEW connections — so treat a no-outage result as inconclusive rather than a pass; the recovery logic's authoritative validation is the unit suites.) To force a real outage (verified v3.10.0): open admin sessions in each user database BEFORE deleting the rule, in case the machine you test from shares the Darling host's egress IP. Delete the rule, wait 6 minutes for it to take effect, then KILL Darling's sessions (`program_name` = `PerformanceMonitorDarling`) from those sessions. KILL is denied in master even for the server admin, so collectors pooled on master keep running. Collectors that open new connections then hit 40615: the `collection_log` message must name the firewall, and those collectors must store rows again after the rule is re-added, with the service's `started_at` unchanged. 5. Clean up: `az group delete --name rg-release-test --yes --no-wait` **AWS RDS:** (same `SQL_TEST_PASSWORD` throwaway as above) @@ -130,7 +132,7 @@ Spin up temporary instances with the Azure CLI (`az`) and AWS CLI (`aws`): aws ec2 revoke-security-group-ingress --group-id --protocol tcp --port 1433 --cidr 0.0.0.0/0 --region us-east-1 ``` -Success criteria: no collector errors in the store's `collection_log` for either cloud server, all applicable collectors landing rows, capability skips recorded as skips rather than failures. +Success criteria: no collector errors in the store's `collection_log` for either cloud server, all applicable collectors landing rows, no inapplicable collector recorded as a failure (RDS records them as capability skips; Azure SQL DB never runs them). ### 6. Lite Collector Validation @@ -156,6 +158,69 @@ Covers the **desktop apps' own** upgrade via Velopack (`*-Setup.exe`) for **Lite Local proxy without a release: bump ``, rebuild, run over the old build → expect the close-and-takeover prompt. The decision logic is unit-tested (`Lite.Tests/SingleInstanceDecisionTests`); this step validates the live Win32/Velopack seam (`SingleInstanceCoordinator` / `ProcessInspector` in `PerformanceMonitor.Ui`). When the seam saw no changes since the prior release, a quick post-publish regression pass is acceptable instead of a pre-cut gate. Design: `plans/single-instance-upgrade-handoff.md`. +### 8a. Darling service install and upgrade (every release) + +Section 8 covers the desktop apps. This section covers the Darling service zip and the Linux tarball. They carry their own install and upgrade scripts, which a Velopack run never touches. A service install that works only on a clean machine is the shape that shipped broken in #5627. So none of this is optional, and the unit suites cannot stand in for it. + +Run the Windows items on a disposable machine or VM, and point them at a throwaway test instance. Keep the machine-neutral rule: no local instance names, hosts or credentials in anything you record. A cloud test instance gets its throwaway `SQL_TEST_PASSWORD`, as in section 5. + +#### (a) The Windows upgrade-shape CI job + +The workflow is `.github/workflows/darling-upgrade-shapes.yml`. It runs 14 legs: seven on `windows-2022` and seven on `windows-2025`. Each leg installs a real released build with that build's own scripts, then runs this commit's install or upgrade script over it. The legs cover four shapes: +- U1: an install on the default virtual account. +- U2: an install whose service logon was changed to a non-default local account. +- U3: an install whose service was deleted. +- U4: a pre-3.9 folder directly under `C:\` that must still refuse and print the move steps. + +The workflow runs on pull requests that touch `Darling/tools/*.ps1`, as the `Darling upgrade shapes` job in the nightly, and by manual dispatch. The release commit often carries no such check, so look for the run at its sha. In the release commit's checks or the nightly, confirm all 14 legs ran at that sha and are green, by name. A skipped leg is not a pass. If there is no run at that sha, start one with `gh workflow run darling-upgrade-shapes.yml --ref ` and wait for it. + +Also read the two pins at the top of the workflow, `DARLING_CURRENT_RELEASE` and `DARLING_OLD_RELEASE`. The first must name the last shipped release, and the second a release from before 3.9 that is still downloadable. Record the run URL. If any leg is red or skipped, stop. A red leg blocks the release, and re-running it until it passes is not a fix. + +#### (b) `upgrade-darling.ps1` over a real existing install + +Do the whole procedure twice: once from the last release, and once from the oldest release still supported. + +- **Which release is the oldest supported:** the oldest `Darling/Darling.Tests/Fixtures/migration-ladder-v.sql` in the tree. `MigrationUpgradeLadderLiveTests` climbs the current ladder over every fixture in that folder. Its `TheMostRecentRelease_HasALadderFixture` check keeps the newest shipped release in the set. So the oldest fixture is the oldest store the tests vouch for. Read the folder at release time, and do not copy a version number into this file. Deleting a fixture drops that release from support, so make it a deliberate decision and not a tidy-up. +- **Set up the old install:** download that release's Darling zip and extract it to `C:\Program Files\PerformanceMonitorDarling`. Write a `darling.json` that points at one throwaway test instance. Install it the way that release documented: its `install-darling.ps1`, or the manual steps in the README's "Upgrading from a build that predates the script" section when that release has no script. Let it collect for at least two cycles. Before upgrading, record `MAX(version)` from `darling_schema_version` and the account the service runs as. Also record the row counts of two or three `collect.*` tables. +- **Upgrade:** extract the new release zip to a staging folder under `C:\Program Files\` that only administrators can write to. From an elevated PowerShell, run that copy's `upgrade-darling.ps1 -Source `. On one of the two runs, point `-Source` at the downloaded zip with `-Sha256` from the release page, so the hash path runs too. When the old release predates the script, the new zip's script still runs over it, because it finds the install through the registered service. If the README still tells those users to take the manual procedure, walk that once as well. Record which path worked. +- **Pass, at the script:** it exits 0 without a refusal. It prints that the SHA256 was verified (zip run), that `darling.json` is byte-identical, that the install manifest was written, and "Service is Running". +- **Pass, 10 to 15 minutes later:** work the five checks the script prints against the store. + 1. `MAX(version)` equals the new build's top rung. + 2. `collect.collection_log` has rows for every configured server in the last 15 minutes. + 3. The collector count is in the mid-30s, not single digits. + 4. Every non-SUCCESS row since the restart gets read. YIELDED is the lock-timeout guard working. Anything else is a finding. + 5. One table the release added or changed shows new rows. + + The row counts you recorded before the upgrade must still be there, and the service account must be unchanged. A service that is Running but not collecting is a fail. +- **What the tests already cover:** the `*RungTests` classes in `Darling/Darling.Tests` pin each migration rung's SQL and probes on every build, with no database. `MigrationUpgradeLadderLiveTests` replays the current ladder over each released store on a scratch PostgreSQL, in the `Darling PostgreSQL tests` job. `DarlingStoreUpgradeTests` and `DarlingStoreUpgradeRevertTests` cover the bundled runtime swap and the in-place PostgreSQL upgrade. They use the old and new runtimes that `Darling/tools/new-upgraded-store-fixture.ps1` builds, and the nightly sets that up. +- **What the live run adds:** none of those tests runs the upgrade script or the Windows service manager. None of them sees the file ownership and permissions of an install an older build made, or a store holding real rows. +- **Record:** the from and to versions, the install path, and the schema version before and after. Add the five check results and the tail of the script's output. + +#### (c) A fresh `install-darling.ps1` run + +Use a machine with no prior install and no `C:\ProgramData\PerformanceMonitorDarling` folder. Extract the release zip to `C:\Program Files\PerformanceMonitorDarling` and copy `darling.sample.json` to `darling.json`. Point it at the test instance, and run `install-darling.ps1` from an elevated PowerShell. + +Pass means three things. The pre-flight `--test-connection` shows PASS for each server. The service is created on its virtual account and reaches Running. The same five store checks hold after 10 to 15 minutes. + +Then run `uninstall-darling.ps1`, with `-PurgeData` on a machine you will reuse, and confirm the service is gone. Record the pre-flight lines and the five check results. + +#### (d) The Linux tarball and the container image + +The release also attaches `PerformanceMonitorDarling-linux-x64-.tar.gz` and `SHA256SUMS-linux.txt`. The `darling-linux` job in `build.yml` builds them: a framework-dependent publish of the service, tarred with no wrapper folder. The same job pushes `ghcr.io/erikdarlingdata/performancemonitor-darling`, tagged with the version and `latest`. + +Linux has no install script, no upgrade script and no systemd unit file in the tarball. The supported shapes are in the README's "Run on Linux" section. One is the compose stack in `Darling/compose/`. The other is the tarball extracted to `/opt/darling` under a unit written from the README's example, with your own PostgreSQL. + +There is no scripted Linux upgrade path. An upgrade is the same manual steps as a fresh install, over an existing store. So the release must check this by hand: + +- **Artifacts:** the tarball and `SHA256SUMS-linux.txt` are on the release, the checksum matches, and the versioned and `latest` image tags exist. +- **Fresh install, compose:** bring the stack up from this release's `Darling/compose/` files on a fresh volume. The compose file names `:latest`, which still points at the previous release until this one publishes. So run the check with this release's own image tag (`:`), set in the compose file or by an override file for the check. Pass: `docker compose ps` shows the darling container healthy (not `unhealthy`). The log shows the config load and `Postgres store ready`. `collection_log` fills, as in the Windows checks. +- **Fresh install, systemd:** extract the tarball to `/opt/darling` and install `libgssapi-krb5-2`. Write the unit from the README. Point `DARLING_CONFIG` at a config for a throwaway PostgreSQL 15+ with TimescaleDB, and start it. Pass: the unit is active and the same store checks hold. +- **Upgrade:** repeat each shape over a store the previous release created. For compose, keep the volume and recreate the container from this release's compose file and image. For systemd, stop the unit, move `/opt/darling` aside, extract the new tarball, keep the config, and start the unit. Pass: the service starts, the store migrates to the new top rung, and collection continues with the earlier rows still present. +- **Oldest supported:** apply the same fixture rule as (b). If that release did not ship a Linux tarball, say so in the record and use the oldest release that did. +- **Record:** the same fields as (b), plus the image digest or tarball checksum you tested. + +Put the outcome of (a) to (d) in the release's test record. An unchecked box blocks the tag. + ### 9. Nightly Build Verification Before cutting the release, verify the nightly build has been clean: diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 098803aba5..5a7e8d2628 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,3 @@ github: erikdarlingdata +patreon: DarlingData +custom: "https://go.erikdarling.com/Donate" diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 841abd0aec..7bf8234ff2 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -85,11 +85,21 @@ body: id: errors attributes: label: Error Messages / Log Output - description: Paste any error messages or relevant log entries. Dashboard logs are in %LOCALAPPDATA%\PerformanceMonitor\Logs\; Lite logs are in %LOCALAPPDATA%\PerformanceMonitorLite-Data\logs\ (on versions before the data-root move, %LOCALAPPDATA%\PerformanceMonitorLite\logs\). + description: Paste any error messages or relevant log entries. Dashboard logs are in %LOCALAPPDATA%\PerformanceMonitor\Logs\; Lite logs are in %LOCALAPPDATA%\PerformanceMonitorLite-Data\logs\ (on versions before the data-root move, %LOCALAPPDATA%\PerformanceMonitorLite\logs\); Darling logs are in %ProgramData%\PerformanceMonitorDarling\logs\. render: text validations: required: false + - type: textarea + id: diagnostics-bundle + attributes: + label: Diagnostics bundle (Darling) + description: | + Run `PerformanceMonitor.Darling.Service.exe --diagnostics-bundle darling-bundle.json` from an elevated prompt in the service folder (Windows). On Linux, run `./PerformanceMonitor.Darling.Service --diagnostics-bundle darling-bundle.json`; for compose, run it with `docker compose exec` and copy the file out with `docker compose cp`. + Drag the file here (rename it to .txt if GitHub refuses .json). Host, server and database names in it are replaced with aliases, and secrets are removed. Open it and check it before you attach it. + validations: + required: false + - type: textarea id: screenshots attributes: diff --git a/.github/ci-history/failures.jsonl b/.github/ci-history/failures.jsonl new file mode 100644 index 0000000000..f6b97b23ad --- /dev/null +++ b/.github/ci-history/failures.jsonl @@ -0,0 +1,731 @@ +{"date":"2026-09-07","head_sha":"7abcaf15220493815da410b23a0509f5f1fa3a8b","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreCopyPhaseTests.cs","Darling/Darling.Tests/StoreWriteReattemptTests.cs","Darling/PerformanceMonitor.Darling.Service/CollectorFaultCopyPhase.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/PostgresTransportFault.cs","Darling/PerformanceMonitor.Darling.Service/StoreWriteReattempt.cs","PerformanceMonitor.Collectors/CollectorContext.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-07","head_sha":"5cba51a487bd08ccb8c252ee1c5efc7393b47714","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreCopyPhaseLivePostgresTests.cs","Darling/Darling.Tests/StoreCopyPhaseTests.cs","Darling/Darling.Tests/StoreWriteReattemptTests.cs","Darling/PerformanceMonitor.Darling.Service/CollectorFaultCopyPhase.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/PostgresTransportFault.cs","Darling/PerformanceMonitor.Darling.Service/StoreWriteReattempt.cs","PerformanceMonitor.Collectors/CollectorContext.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-07","head_sha":"a059fe218a30f9d998ea9396cbb379855c423bdf","event":"pull_request","changed_files":["Darling/Darling.Tests/PgIndexBloatBudgetLivePostgresTests.cs","Lite.Tests/PgIndexBloatCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/PgIndexBloatCollector.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.PgIndexBloatBudgetLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-07","head_sha":"cc29d98125b8f796b66447114bf3c23dd958e156","event":"pull_request","changed_files":["Darling/docs/L3116-skip-arm-probe.md"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Darling whole-tree guards"]} +{"date":"2026-09-07","head_sha":"a304350fe32f3fb286518a4c73b9f626785ae4bb","event":"pull_request","changed_files":["Darling/Darling.Tests/RepoFileAdoptionTests.cs","Lite.Tests/LiteLogLevelGateTests.cs","Lite/App.xaml.cs","Lite/Services/AppLogger.cs","Lite/Services/AppLoggerAdapter.cs","Lite/Services/RemoteCollectorService.DefinitionRunner.cs","Lite/config/settings.sample.json","README.md"],"failed_classes":["Lite.Tests.LiteServerTagsStoreTests"],"failed_jobs":["build"]} +{"date":"2026-09-07","head_sha":"4205ef83306e362c854c9b8285eadb949b034e15","event":"pull_request","changed_files":[".github/workflows/build.yml"],"failed_classes":["Darling.Tests.DarlingAnalysisBudgetTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-07","head_sha":"01fdeae92aaba8b87216c9e69ec87d9787d03e61","event":"push","changed_files":["Darling/Darling.Tests/PostgresCancelOriginTests.cs","Darling/Darling.Tests/PostgresFaultOutcomeTests.cs","Darling/Darling.Tests/StoreCopyPhaseTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Service/CollectorFaultCancelOrigin.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/PostgresTargetProvider.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DarlingDeployStaleFileTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-07","head_sha":"4f19be020a4dcca78c8098aa13d3c4db5b551d0d","event":"pull_request","changed_files":["Lite.Tests/Lite.Tests.csproj","Lite.Tests/SharedDuckDbFixture.cs","Lite.Tests/WriteLockBudgetTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/LocalDataService.cs"],"failed_classes":["Lite.Tests.CrossAppGuardCiGateTests"],"failed_jobs":["build"]} +{"date":"2026-09-07","head_sha":"bf7c8e6bc8f00f5c7fd617639c858e94dd62b34d","event":"pull_request","changed_files":["Lite.Tests/Lite.Tests.csproj","Lite.Tests/SharedDuckDbFixture.cs","Lite.Tests/WriteLockBudgetTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/LocalDataService.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build"]} +{"date":"2026-09-07","head_sha":"39a702e4f9d724e3fd0558c46b2587d19fed1a1e","event":"pull_request","changed_files":[".github/dependabot.yml",".github/workflows/build.yml",".github/workflows/nightly.yml","Darling/Darling.Tests/LockedModeRestoreCoverageTests.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-07","head_sha":"237f69c04c3caaf1e10425f98f2d3f4b623039a0","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Lite.Tests/AlertReadFailureSurfaceTests.cs","Lite/Mcp/McpHealthTools.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-08","head_sha":"3cb494b9abdf5e94f948c823a36088e89ab3e15a","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionLogDrainForensicsStoreTests.cs","Darling/Darling.Tests/CollectionLogPhaseSplitStoreTests.cs","Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/DarlingEmptyEnumerationNoteTests.cs","Darling/Darling.Tests/ServerScopePhaseSplitTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/CollectorMeasurementNoteTests.cs","Lite.Tests/EmptyEnumerationNoteTests.cs","Lite/Services/RemoteCollectorService.DefinitionRunner.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/CollectorMeasurement.cs"],"failed_classes":["Darling.Tests.StoreWriteReattemptTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-08","head_sha":"28d98e40af1261cfde60fbe390499d7f5a9b6748","event":"pull_request","changed_files":["Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.RefreshCeilingProvenancePinTests","Darling.Tests.TimescaleSupportTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-08","head_sha":"e97c2bb91639812dd11619bacf881c7a9333686e","event":"pull_request","changed_files":["Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.RefreshCeilingProvenancePinTests","Darling.Tests.TimescaleSupportTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-08","head_sha":"ee66f9a95d092000231122ba262601982d5e3a8b","event":"pull_request","changed_files":["Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/OperatorRemediationFlowTests.cs","Darling/Darling.Tests/OperatorRemediationGateTests.cs","Darling/Darling.Tests/PlanForceActionStoreTests.cs","Darling/Darling.Tests/PostgresTargetConfigTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/MonitoredServerConnection.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceBot.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/tools/provision-roles.sql","Lite.Tests/OperatorRemediationLiteDivergencePinTests.cs","PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs","PerformanceMonitor.Analysis/OperatorRemediationFlow.cs","PerformanceMonitor.Analysis/OperatorRemediationGate.cs"],"failed_classes":["Darling.Tests.CollectorStallProbeStoreTests","Darling.Tests.CollectorStallProbeViewerGateTests","Darling.Tests.DarlingManagedRolesTests","Darling.Tests.DarlingRetentionTests","Darling.Tests.RegisteredServerSettingDriftTests","Lite.Tests.OperatorRemediationLiteDivergencePinTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-08","head_sha":"c398ba40dceec3a89503740aee292cf78fb9d1a8","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertDeliveryChannelTests.cs","Darling/Darling.Tests/DarlingAlertingTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertDeliverer.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFindingAlertSender.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs","Lite.Tests/AlertDeliveryChannelTests.cs","Lite.Tests/StoreRoundTripTests.cs","Lite/Services/EmailAlertService.cs","Lite/Services/LocalDataService.AlertHistory.cs","PerformanceMonitor.Notifications/AlertDelivery.cs","PerformanceMonitor.Notifications/EmailSendCore.cs","PerformanceMonitor.Notifications/IAlertHistoryStore.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs","deprecated/Dashboard.Tests/DashboardAlertHistoryValueTests.cs","deprecated/Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs","deprecated/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs","deprecated/Dashboard/Services/EmailAlertService.cs"],"failed_classes":["Darling.Tests.DarlingAlertingTests","Darling.Tests.DarlingDeliveryModeTests","Darling.Tests.DarlingSelfAlertTests","Darling.Tests.ViewerWave3LivePostgresTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-08","head_sha":"52aabbdf74646007a9ead98a94ad99b46d081f1c","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorStallProbeStoreTests.cs","Darling/Darling.Tests/CollectorStallProbeViewerGateTests.cs","Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingRetentionTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/OperatorRemediationFlowTests.cs","Darling/Darling.Tests/OperatorRemediationGateTests.cs","Darling/Darling.Tests/PlanForceActionStoreTests.cs","Darling/Darling.Tests/PostgresTargetConfigTests.cs","Darling/Darling.Tests/RegisteredServerSettingDriftTests.cs","Darling/Darling.Tests/RemediationCredentialRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/MonitoredServerConnection.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceBot.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/tools/provision-roles.sql","Lite.Tests/OperatorRemediationLiteDivergencePinTests.cs","PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs","PerformanceMonitor.Analysis/OperatorRemediationFlow.cs","PerformanceMonitor.Analysis/OperatorRemediationGate.cs"],"failed_classes":["Darling.Tests.RegisteredServerSettingDriftTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-08","head_sha":"455aefc3501c4441acb9bf05cded3aa49b659f37","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-08","head_sha":"b8ae88ab2dea213b09adfb12d62b8f7806db778f","event":"push","changed_files":["Darling/Darling.Tests/CollectorStallProbeStoreTests.cs","Darling/Darling.Tests/CollectorStallProbeViewerGateTests.cs","Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingRetentionTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/OperatorRemediationFlowTests.cs","Darling/Darling.Tests/OperatorRemediationGateTests.cs","Darling/Darling.Tests/PlanForceActionStoreTests.cs","Darling/Darling.Tests/PostgresTargetConfigTests.cs","Darling/Darling.Tests/RegisteredServerSettingDriftTests.cs","Darling/Darling.Tests/RemediationCredentialRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/MonitoredServerConnection.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceBot.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Darling/tools/provision-roles.sql","Lite.Tests/OperatorRemediationLiteDivergencePinTests.cs","Lite/Analysis/Recommendations/LiteRecommendationItem.cs","Lite/Analysis/Recommendations/LiteRecommendationsViewModel.cs","Lite/Controls/RecommendationsTab.xaml.cs","PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs","PerformanceMonitor.Analysis/OperatorRemediationFlow.cs","PerformanceMonitor.Analysis/OperatorRemediationGate.cs"],"failed_classes":["Darling.Tests.TimescaleSupportTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-08","head_sha":"8931e0a761f3c9c657390f7842287912b19ae8dc","event":"pull_request","changed_files":["Darling/Darling.Tests/CompressionClearanceWatchTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-08","head_sha":"4442d28b0bdc3e978cc4b5142ebbb2190dea51c6","event":"pull_request","changed_files":["Darling/Darling.Tests/RefreshCeilingStalenessTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests","Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-08","head_sha":"11d610329cf955d7a85988f4ededd0efcebda619","event":"push","changed_files":["Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs","Darling/Darling.Tests/RefreshCeilingStalenessTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Lite.Tests.CrossAppGuardCiGateTests"],"failed_jobs":["build"]} +{"date":"2026-09-09","head_sha":"89f49f5a5f57d08f104bae71a8f1adc459853d71","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadClockFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCorrectionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPvsReader.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpJobTools.cs","Lite/Mcp/McpObjectStatsTools.cs","Lite/Mcp/McpPlanCorrectionTools.cs","Lite/Mcp/McpPvsTools.cs","Lite/Mcp/McpServerLocalWindow.cs"],"failed_classes":["Darling.Tests.McpPayloadClockFrameDisciplineTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-09","head_sha":"b7c622ac9bde644a6ab50f344f362731e7ab9080","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreFetchProbeLivePostgresTests.cs","Darling/Darling.Tests/QueryStorePlanFetchTests.cs","Darling/Darling.Tests/QueryStoreTouchGuardSingleSourceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreLivenessTouchGuard.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStorePlanMap.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreTextStore.cs"],"failed_classes":["Darling.Tests.DarlingDeployRollbackRetentionTests"],"failed_jobs":["build"]} +{"date":"2026-09-09","head_sha":"7e696667e77211df9484c488200035ecb7e059aa","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadClockFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCorrectionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPvsReader.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpJobTools.cs","Lite/Mcp/McpObjectStatsTools.cs","Lite/Mcp/McpPlanCorrectionTools.cs","Lite/Mcp/McpPvsTools.cs","Lite/Mcp/McpServerLocalWindow.cs"],"failed_classes":["Darling.Tests.DarlingMcpObjectStatsToolsSurfaceAndSqlTests","Darling.Tests.DarlingMcpPlanCorrectionToolsTests","Darling.Tests.DarlingMcpPvsToolsTests","Darling.Tests.IndexUsageTruncationTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-09","head_sha":"038366d4fce56bed40f318801165fb23c54f4558","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPgReadSqlParsesLiveTests.cs","Darling/Darling.Tests/StoreSqlClockDisciplineTests.cs"],"failed_classes":["Lite.Tests.CrossAppGuardCiGateTests"],"failed_jobs":["build"]} +{"date":"2026-09-09","head_sha":"c849c9f134c34e84826c15ac24f2d6b85b033dab","event":"pull_request","changed_files":["Lite.Tests/EntraCredentialSelectionModeGateTests.cs","Lite.Tests/EntraCredentialSelectionOrderingTests.cs","Lite.Tests/EntraCredentialSelectionTests.cs","Lite/Helpers/EntraCredentialSelection.cs","Lite/Services/ServerManager.cs","Lite/Windows/AddServerDialog.xaml.cs","README.md"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build"]} +{"date":"2026-09-09","head_sha":"b3d65a3f8686f1fb530d9fe1f7965bdb6613c9a5","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadClockFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCorrectionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPvsReader.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpJobTools.cs","Lite/Mcp/McpObjectStatsTools.cs","Lite/Mcp/McpPlanCorrectionTools.cs","Lite/Mcp/McpPvsTools.cs","Lite/Mcp/McpServerLocalWindow.cs"],"failed_classes":["Darling.Tests.ConsumedTimestampFrameDisciplineTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-09","head_sha":"160cf82a247aa08021adf115383926619f4c66da","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/McpPayloadClockFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCorrectionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPvsReader.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpJobTools.cs","Lite/Mcp/McpObjectStatsTools.cs","Lite/Mcp/McpPlanCorrectionTools.cs","Lite/Mcp/McpPvsTools.cs","Lite/Mcp/McpServerLocalWindow.cs"],"failed_classes":["Darling.Tests.ConsumedTimestampFrameDisciplineTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-09","head_sha":"0ed7cb858dc05624f1eb388d07ece82a27c263e0","event":"pull_request","changed_files":[".claude/skills/release-checklist/SKILL.md",".github/workflows/build.yml",".github/workflows/check-version-bump.yml",".github/workflows/nightly.yml","Darling/Darling.Tests/ProductVersionDeclarationTests.cs","Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj","Darling/PerformanceMonitor.Darling.Viewer/PerformanceMonitor.Darling.Viewer.csproj","Directory.Build.props","Lite/PerformanceMonitorLite.csproj","PerformanceMonitor.Common/Services/UpdateCheckService.cs","build-dashboard.cmd","build-lite.cmd","deprecated/Dashboard/Dashboard.csproj","deprecated/Installer.Core/Installer.Core.csproj","deprecated/Installer/PerformanceMonitorInstaller.csproj","package-release.cmd"],"failed_classes":["Lite.Tests.CrossAppGuardCiGateTests"],"failed_jobs":["build"]} +{"date":"2026-09-09","head_sha":"3c76c11b4b53d998d93f39324efbc305643867ea","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/McpPayloadClockFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCorrectionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPvsReader.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpJobTools.cs","Lite/Mcp/McpObjectStatsTools.cs","Lite/Mcp/McpPlanCorrectionTools.cs","Lite/Mcp/McpPvsTools.cs","Lite/Mcp/McpServerLocalWindow.cs"],"failed_classes":["Darling.Tests.ConsumedTimestampFrameDisciplineTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-09","head_sha":"d6da0314eaf991e5a04685b9a9f5cb3b7cbe4d68","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/ViewerClockRendererArithmeticTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs","Lite.Tests/ServerClockRendererArithmeticTests.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.JobHistory.cs","Lite/Services/LocalDataService.PlanCorrection.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.RunningJobs.cs","Lite/Services/ServerTimeHelper.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-09","head_sha":"c6bcbae7d8f07bb0bb7ddc0f95ed71df5625d7aa","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.RunningJobs.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerHistoryRows.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.PlanCorrection.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.RunningJobs.cs","Lite/Services/ServerTimeHelper.cs"],"failed_classes":["Darling.Tests.ConsumedTimestampFrameDisciplineTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-09","head_sha":"5b0518b0242a0f25309596bff916345066c689d5","event":"pull_request","changed_files":["Darling/Darling.Tests/CSharpMemberMap.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-09","head_sha":"7b64070c982d28d099d82d5a11de44730cc7abac","event":"pull_request","changed_files":["CHANGELOG.md"],"failed_classes":["Darling.Tests.DarlingDeployRollbackRetentionTests"],"failed_jobs":["Darling whole-tree guards"]} +{"date":"2026-09-09","head_sha":"91cb1fb7226690c7326d6842e97d221e61b4e8a5","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorStateContractTests.cs","Darling/Darling.Tests/DarlingPgIndexBloatNanLivePostgresTests.cs","Darling/Darling.Tests/DarlingPgIndexBloatRotationReadLivePostgresTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/PgExtensionDependencyContractTests.cs","Darling/Darling.Tests/PgIndexBloatBudgetLivePostgresTests.cs","Darling/Darling.Tests/PgIndexBloatEstimateRungTests.cs","Darling/Darling.Tests/PgRotationCursorPruneLivePostgresTests.cs","Darling/Darling.Tests/RemediationCredentialRungTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIndexTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgIndexBloatReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Darling/README.md","Lite.Tests/PgIndexBloatCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/PgIndexBloatCollector.cs","PerformanceMonitor.Collectors/PgPerDatabaseCollectorState.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Lite.Tests.DefaultTraceEventsCollectorDefinitionTests","Lite.Tests.PgIndexBloatCollectorDefinitionTests"],"failed_jobs":["build"]} +{"date":"2026-09-10","head_sha":"f28452c053d445321e12577fbc4d52fe5716f8bd","event":"pull_request","changed_files":["CHANGELOG.md","Directory.Build.props","README.md"],"failed_classes":["Darling.Tests.MigrationUpgradeLadderLiveTests"],"failed_jobs":["build"]} +{"date":"2026-09-10","head_sha":"1d4dbcaec03c8f40a62e32698398c242808088d5","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorRunnerConnectionEngineTests.cs","Darling/Darling.Tests/ReadmeDerivedCountPinTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgStatementTools.cs","Darling/README.md"],"failed_classes":["Lite.Tests.AuroraOnlySqlIsGatedTests"],"failed_jobs":["build"]} +{"date":"2026-09-10","head_sha":"565b89dd45c5d44db9f00fa5871482e67c2d2daa","event":"pull_request","changed_files":["CHANGELOG.md","Directory.Build.props","README.md"],"failed_classes":["Darling.Tests.MigrationUpgradeLadderLiveTests"],"failed_jobs":["build"]} +{"date":"2026-09-10","head_sha":"1e1c66bd144edcce9544a6bec88e00e1cee3ff8b","event":"pull_request","changed_files":["CHANGELOG.md","Directory.Build.props","README.md"],"failed_classes":["Darling.Tests.MigrationUpgradeLadderLiveTests"],"failed_jobs":["build"]} +{"date":"2026-09-11","head_sha":"f5e2f565551a22a4d7c94f69ce60accf059a94db","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPgIndexBloatCoverageLivePostgresTests.cs","Darling/Darling.Tests/PgIndexBloatCoverageTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIndexTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgIndexBloatReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","PerformanceMonitor.Collectors/PgIndexBloatCoverage.cs"],"failed_classes":["Darling.Tests.DarlingPgReadSqlParsesLiveTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-11","head_sha":"132b7ee191c3ae276e6bff7358209fd184a525f5","event":"push","changed_files":["Darling/Darling.Tests/DarlingPgIndexBloatCoverageLivePostgresTests.cs","Darling/Darling.Tests/DarlingPgReadSqlParsesLiveTests.cs","Darling/Darling.Tests/PgIndexBloatCoverageTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/TsqlConventionGuardTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIndexTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgIndexBloatReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","PerformanceMonitor.Collectors/PgIndexBloatCoverage.cs"],"failed_classes":["Darling.Tests.DarlingPgIndexBloatCoverageLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-11","head_sha":"ef216f63d7822d64cf4eb9042bfc96d35f174691","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetCardPostgresCpuTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/Darling.Tests/PgIndexBloatEstimateRungTests.cs","Darling/Darling.Tests/ServerHealthClassifierTests.cs","Darling/Darling.Tests/UnmeasuredMetricsAreNotHealthyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCpuIngestor.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/PgCpuUtilizationCollector.cs","PerformanceMonitor.Common/FleetCpuSource.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DarlingPgIndexBloatCoverageLivePostgresTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.FleetCardPostgresCpuLivePostgresTests","Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-11","head_sha":"4044fe11ba1a89a4376b685dcd2bcd9951f56447","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetCardPostgresCpuLivePostgresTests.cs","Darling/Darling.Tests/FleetCardPostgresCpuTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/Darling.Tests/PgIndexBloatEstimateRungTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/ServerHealthClassifierTests.cs","Darling/Darling.Tests/UnmeasuredMetricsAreNotHealthyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCpuIngestor.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/PgCpuUtilizationCollector.cs","PerformanceMonitor.Common/FleetCpuSource.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DarlingPgIndexBloatCoverageLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-11","head_sha":"81c136a724e74c270fde5b58b78e93c2b9a9bc59","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetCardPostgresCpuLivePostgresTests.cs","Darling/Darling.Tests/FleetCardPostgresCpuTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/Darling.Tests/PgIndexBloatEstimateRungTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/ServerHealthClassifierTests.cs","Darling/Darling.Tests/UnmeasuredMetricsAreNotHealthyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCpuIngestor.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/PgCpuUtilizationCollector.cs","PerformanceMonitor.Common/FleetCpuSource.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DarlingPgIndexBloatCoverageLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-11","head_sha":"1f1d465e0c41c6eb67309e24f27ba5ceabe0ec77","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetCardPostgresCpuLivePostgresTests.cs","Darling/Darling.Tests/FleetCardPostgresCpuTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/Darling.Tests/PgIndexBloatEstimateRungTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/ServerHealthClassifierTests.cs","Darling/Darling.Tests/UnmeasuredMetricsAreNotHealthyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCpuIngestor.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/PgCpuUtilizationCollector.cs","PerformanceMonitor.Common/FleetCpuSource.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DarlingPgIndexBloatCoverageLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-11","head_sha":"5d82981557501a9ee1aa7e89d4eb37a2f6a0b423","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertPersistenceGateTests.cs","Darling/Darling.Tests/CustomAlertRuleDefinitionTests.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleDefinition.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertStateStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Alerting/AlertPersistenceGate.cs"],"failed_classes":["Darling.Tests.PgCpuCapacityHeadroomTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-11","head_sha":"a9316fcc8f5ab9c55a17b54714aac9e0196e50ba","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpCustomAlertToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs"],"failed_classes":["Darling.Tests.DarlingWebEndpointsTests","Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-11","head_sha":"04c3c1a4c8bd8a85a7f2f97fb9e8a5c200541025","event":"pull_request","changed_files":["Darling/Darling.Tests/CustomAlertCoreMigrationTests.cs","Darling/Darling.Tests/MuteRuleReloadBeaconLivePostgresTests.cs","Darling/Darling.Tests/MuteRuleReloadBeaconTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServiceConfig.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.MuteRuleReloadBeaconTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-11","head_sha":"eb5ad9acfc02e7c5c927e21440c3e1c0ccb5b656","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs","Darling/Darling.Tests/CustomAlertCoreMigrationTests.cs","Darling/Darling.Tests/DarlingAlertingTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgAlertStateStore.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite.Tests/DuckDbSchemaTests.cs","Lite.Tests/LiteAlertForwardingTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/Schema.cs","Lite/MainWindow.AlertEngine.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/LiteAlertStateStore.cs","Lite/Services/LocalDataService.Overview.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/AlertPersistenceRecord.cs","PerformanceMonitor.Alerting/AlertServerSnapshot.cs","PerformanceMonitor.Alerting/IAlertStateStore.cs","README.md"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.RepoFileAdoptionTests","PerformanceMonitorLite.Tests.LiteAlertForwardingTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-11","head_sha":"8c1231a7772923741c1d2ba4daa9781661030584","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs","Darling/Darling.Tests/CustomAlertCoreMigrationTests.cs","Darling/Darling.Tests/DarlingAlertingTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/MuteRuleReloadBeaconTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgAlertStateStore.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite.Tests/DuckDbSchemaTests.cs","Lite.Tests/LiteAlertForwardingTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/Schema.cs","Lite/MainWindow.AlertEngine.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/LiteAlertStateStore.cs","Lite/Services/LocalDataService.Overview.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/AlertPersistenceRecord.cs","PerformanceMonitor.Alerting/AlertServerSnapshot.cs","PerformanceMonitor.Alerting/IAlertStateStore.cs","README.md"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-11","head_sha":"f0a678328ecc8618d9f2bf34bc9db903e9100c5b","event":"pull_request","changed_files":["Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs","Darling/PerformanceMonitor.Darling.Service/PgAlertStateStore.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/Schema.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/LiteAlertStateStore.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-12","head_sha":"ccea95f0e5f61080dabc27f5bbf6f4d0b70fff9b","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/MuteRule.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-12","head_sha":"e6dbdbea94c340a7f51f8b2a7426ee5200b14e5b","event":"pull_request","changed_files":["CHANGELOG.md"],"failed_classes":["Darling.Tests.IncidentAttachmentDeliveryTests"],"failed_jobs":["Darling whole-tree guards"]} +{"date":"2026-09-12","head_sha":"c089bad9ede15eca9ad8e173b6a8e7c4e3a8e6aa","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","PerformanceMonitor.Notifications/MuteRule.cs"],"failed_classes":["Darling.Tests.IncidentAttachmentDeliveryTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-12","head_sha":"cca860fb66b2009c22e5fb45f015806093b07dc6","event":"pull_request","changed_files":["Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs","Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/RetentionHoldRatioKnobRungTests.cs","Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite.Tests/McpAlertSettingsKeyTests.cs","Lite/Mcp/McpAlertTools.cs","docs/retention-hold-runbook.md"],"failed_classes":["Darling.Tests.ConfigSeedStatementArityTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-12","head_sha":"25bae3e1b1e82163ca989c846fc60a5fdd7a6c0a","event":"push","changed_files":["Darling/PerformanceMonitor.Darling.Service/wwwroot/css/editor.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/alert-editor.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/alert-seed.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/alerts-api.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/notebook.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alert-rules.js"],"failed_classes":["PerformanceMonitorLite.Tests.AnalysisPassTokenThreadingTests"],"failed_jobs":["build"]} +{"date":"2026-09-12","head_sha":"dd945402303b54c9703b12e46008223aabf4d5de","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCollectionLogReadTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Lite.Tests/CollectionLogToolTests.cs","Lite/Mcp/McpHealthTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Services/LocalDataService.CollectionHealth.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.DarlingCollectionLogReadTests","Darling.Tests.QueryStoreTopWindowTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-13","head_sha":"34467c3f02617e0c992320364df30e666225f594","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreProbeCacheTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreProbeCache.cs","Lite.Tests/ProcedureStatsCollectorDefinitionTests.cs","Lite.Tests/QueryStatsCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/ProcedureStatsCollector.cs","PerformanceMonitor.Collectors/QueryPlanXmlCaptureLimits.cs","PerformanceMonitor.Collectors/QueryStatsCollector.cs"],"failed_classes":["Darling.Tests.FleetIdentifierScrubTests","Darling.Tests.ProcedureStatsPlanCadenceTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-13","head_sha":"f850aa9c9db06aa16a77eccc3f6a89df3e1b381f","event":"pull_request","changed_files":["Lite.Tests/CrossAppGuardCiGateTests.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling whole-tree guards"]} +{"date":"2026-09-13","head_sha":"bb916117b252cd8b1f37d37bedf18355d2da86e6","event":"pull_request","changed_files":["Darling/Darling.Tests/OversizedPlanBacklogReadPins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpOversizedPlanBacklogTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingOversizedPlanBacklogReader.cs","Darling/README.md","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-14","head_sha":"c986bf22e295120a2a550f4754593984ac7d5363","event":"pull_request","changed_files":["Darling/Darling.Tests/OversizedPlanBacklogPins.cs","Darling/Darling.Tests/OversizedPlanSweepRunRecordTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingObservability.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpOversizedPlanBacklogTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs","Darling/PerformanceMonitor.Darling.Service/OversizedPlanBacklogSweep.cs","Darling/PerformanceMonitor.Darling.Storage/OversizedPlanBacklog.cs","Darling/README.md","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs"],"failed_classes":["Darling.Tests.OversizedPlanBacklogPins"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-15","head_sha":"e1059d5a0b007036abb89975da7e9883c80c48e4","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorCostRegressionDispersionTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCollectorCostReader.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-15","head_sha":"13c20722281c933fce20b75c3d6e823acb2e5a83","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorCostRegressionDispersionTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCollectorCostReader.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-15","head_sha":"26e3eef0b4b9d3d3f5827612aa879c28a106f848","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertDeliveryChannelTests.cs","Darling/Darling.Tests/AlertTrayStatusLoggedTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Lite.Tests/AlertDeliveryChannelTests.cs","Lite.Tests/PagerDutyWebhookTests.cs","Lite.Tests/StoreRoundTripTests.cs","Lite.Tests/WebhookCooldownSeedTests.cs","Lite/Mcp/McpAlertTools.cs","PerformanceMonitor.Notifications/AlertDelivery.cs","PerformanceMonitor.Notifications/EmailSendCore.cs","PerformanceMonitor.Notifications/IAlertHistoryStore.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.RepeatDeliveryBudgetTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-15","head_sha":"42d5ad5eefebae9b13b8dc22edb3df105fa618e6","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorCostDigestTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCollectorCostReader.cs","Lite.Tests/AlertSeverityTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertSeverity.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.CollectorCostDigestTests","Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-15","head_sha":"2e3852241d11a040cd96c12ccdd06efd00a3c5aa","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/OversizedPlanBacklogPins.cs","Darling/Darling.Tests/PgAlertCountKnobRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Alerting/PostgresAlertEvaluator.cs"],"failed_classes":["Lite.Tests.McpAlertSettingsKeyTests"],"failed_jobs":["build"]} +{"date":"2026-09-15","head_sha":"95d3f7799ccdd33858d3f3664f0c46eaf7f8b464","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionLogDrainForensicsStoreTests.cs","Darling/Darling.Tests/PgLoggingCollectorOffTests.cs","Darling/Darling.Tests/PostgresFaultOutcomeTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgDeadlockTools.cs","Lite.Tests/PgLoggingCollectorGateTests.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLoggingCollectorOffException.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-16","head_sha":"601ab8a7cbe6bf24dde6a9274aa6f3e6cf32ca7f","event":"pull_request","changed_files":["CHANGELOG.md","Darling/Darling.Tests/AlertMasterSwitchSurfaceTests.cs","Darling/Darling.Tests/SameStatementPileupSourceCensusTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Analysis/PgPileupSnapshotReader.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/README.md","Lite.Tests/SameStatementPileupDetectorTests.cs","Lite/Analysis/FindingStore.cs","Lite/Analysis/PileupSnapshotReader.cs","Lite/Services/CollectionBackgroundService.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/SameStatementPileupDetector.cs"],"failed_classes":["Darling.Tests.DarlingAnalysisStoreTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-16","head_sha":"2b45c772a7a3b55520f439291e936e1cb8bd1aa6","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/FleetSweepCadenceKnobRungTests.cs","Darling/Darling.Tests/FleetSweepEngineTests.cs","Darling/Darling.Tests/FleetSweepStateRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/FleetSweepEngine.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/FleetSweepCadence.cs","Darling/PerformanceMonitor.Darling.Storage/FleetSweepStore.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite.Tests/McpAlertSettingsKeyTests.cs"],"failed_classes":["Darling.Tests.PgAlertCountKnobRungTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-16","head_sha":"7301d2a37766df19e97e6cf23f87e23f64415c50","event":"pull_request","changed_files":["CHANGELOG.md","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs","Darling/Darling.Tests/DarlingRetentionTests.cs","Darling/Darling.Tests/FleetSweepRollupTests.cs","Darling/Darling.Tests/FleetSweepStateRungTests.cs","Darling/Darling.Tests/McpToolTypeRegistrationTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Storage/FleetSweepStore.cs","Darling/README.md","Lite.Tests/AlertSeverityTests.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertSeverity.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-16","head_sha":"f3c7ab2115308f2127eaac8dd5e03b58eb19b2fb","event":"pull_request","changed_files":["CHANGELOG.md","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs","Darling/Darling.Tests/DarlingRetentionTests.cs","Darling/Darling.Tests/FleetSweepRollupTests.cs","Darling/Darling.Tests/FleetSweepStateRungTests.cs","Darling/Darling.Tests/McpToolTypeRegistrationTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Storage/FleetSweepStore.cs","Darling/README.md","Lite.Tests/AlertSeverityTests.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertSeverity.cs","README.md","llms.txt"],"failed_classes":["Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-16","head_sha":"a2829f46d5c779c42f3e611023174433e744c06b","event":"pull_request","changed_files":["CHANGELOG.md","Darling/Darling.Tests/StoreIsOnThisMachineTests.cs","Darling/Darling.Tests/ViewerServerManagementTests.cs","Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml","Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ServerStoreCredential.cs"],"failed_classes":["Darling.Tests.BulkServerOnboardingMappingTests","PerformanceMonitorLite.Tests.EntraDeviceCodeTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-17","head_sha":"bfedaca4bc22ddc889e9a9ec0fd7a6afdb59726a","event":"pull_request","changed_files":["CHANGELOG.md","Darling/Darling.Tests/StoreIsOnThisMachineTests.cs","Darling/Darling.Tests/ViewerServerManagementTests.cs","Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml","Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ServerStoreCredential.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerMigration.cs","Lite.Tests/EntraDeviceCodeTests.cs"],"failed_classes":["Darling.Tests.BulkServerOnboardingMappingTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-17","head_sha":"5a71cef415797316d889da18b4080980ed41cf0a","event":"pull_request","changed_files":["CHANGELOG.md","Darling/Darling.Tests/PostgresAddServerDialogTests.cs","Darling/Darling.Tests/ServerIdentitySurvivesAnEditTests.cs","Darling/Darling.Tests/ViewerServerManagementTests.cs","Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-17","head_sha":"4e102377669ea939b0dfe32dada5c385e43e0021","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs","Darling/Darling.Tests/FleetSweepCadenceKnobRungTests.cs","Darling/Darling.Tests/PostgresDatabaseListScreenLivePostgresTests.cs","Darling/Darling.Tests/TargetProviderTests.cs","Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/Targets/PostgresTargetProvider.cs","Darling/PerformanceMonitor.Darling.Service/Targets/SqlServerTargetProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleOverlay.cs","Darling/PerformanceMonitor.Darling.Viewer/CollectorSchedulePresets.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/DatabaseScopeFilter.cs","PerformanceMonitor.Collectors/DatabaseScopedConfigCollector.cs","PerformanceMonitor.Collectors/ITargetProvider.cs","PerformanceMonitor.Collectors/IndexObjectStatsCollector.cs","PerformanceMonitor.Collectors/PlanCorrectionCollector.cs","PerformanceMonitor.Collectors/QueryStoreCollector.cs","PerformanceMonitor.Collectors/QueryStoreHealthCollector.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-17","head_sha":"76f3ad71d0117c2ed2cca3b92af6fd763d6470d4","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorDatabaseScopeLivePostgresTests.cs","Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs","Darling/Darling.Tests/FleetSweepCadenceKnobRungTests.cs","Darling/Darling.Tests/PostgresDatabaseListScreenLivePostgresTests.cs","Darling/Darling.Tests/TargetProviderTests.cs","Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/Targets/PostgresTargetProvider.cs","Darling/PerformanceMonitor.Darling.Service/Targets/SqlServerTargetProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleEditorWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/CollectorScheduleOverlay.cs","Darling/PerformanceMonitor.Darling.Viewer/CollectorSchedulePresets.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectorSchedules.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/DatabaseScopeFilter.cs","PerformanceMonitor.Collectors/DatabaseScopedConfigCollector.cs","PerformanceMonitor.Collectors/ITargetProvider.cs","PerformanceMonitor.Collectors/IndexObjectStatsCollector.cs","PerformanceMonitor.Collectors/PlanCorrectionCollector.cs","PerformanceMonitor.Collectors/QueryStoreCollector.cs","PerformanceMonitor.Collectors/QueryStoreHealthCollector.cs"],"failed_classes":["Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-17","head_sha":"4242f787997a41af6802b2544fe1f3a7d7bc352b","event":"pull_request","changed_files":["Darling/Darling.Tests/AgentJobStepQueryTests.cs","Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/AlertContextBuildersTests.cs","Lite/MainWindow.AlertEngine.cs","Lite/MainWindow.xaml.cs","Lite/Services/RemoteCollectorService.RunningJobs.cs","PerformanceMonitor.Alerting/AgentJobStepQuery.cs","PerformanceMonitor.Alerting/AlertContextBuilders.cs","PerformanceMonitor.Alerting/AlertEngine.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-17","head_sha":"76e349dcda5d6f4f158c90472e78abeadf505f4c","event":"pull_request","changed_files":["Darling/Darling.Tests/AgentJobStepQueryTests.cs","Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/AlertContextBuildersTests.cs","Lite/MainWindow.AlertEngine.cs","Lite/MainWindow.xaml.cs","Lite/Services/RemoteCollectorService.RunningJobs.cs","PerformanceMonitor.Alerting/AgentJobStepQuery.cs","PerformanceMonitor.Alerting/AlertContextBuilders.cs","PerformanceMonitor.Alerting/AlertEngine.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"1d56092cad4e6fdefa0cf5aacc3003062c3b49ae","event":"pull_request","changed_files":["Darling/Darling.Tests/DailyDeadlockWindowCensusTests.cs","Darling/Darling.Tests/DailyHealthBandTests.cs","Darling/Darling.Tests/DarlingMcpHealthToolsTests.cs","Darling/Darling.Tests/FleetSweepEngineTests.cs","Darling/PerformanceMonitor.Darling.Service/FleetSweepEngine.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs","Lite.Tests/DailyHealthBandTests.cs","Lite.Tests/PerformanceCalendarDataTests.cs","Lite/Services/LocalDataService.DailySummary.cs","PerformanceMonitor.Common/DailyHealthBand.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DarlingMcpHealthToolsLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"e9f5484eadb29bb7464f543c67a4a2830c1de9a6","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/DarlingAlertReadAdapterTests.cs","Darling/Darling.Tests/DarlingAlertingTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/PostgresAlertEvaluatorTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/README.md","Lite.Tests/AlertContextBuildersTests.cs","Lite.Tests/BlockingDeadlockContextBuilderTests.cs","Lite.Tests/LiteAlertForwardingTests.cs","Lite/Services/AppAlertEngineSettings.cs","Lite/Services/LiteAlertReadAdapter.cs","Lite/Services/LocalDataService.WaitStats.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/IAlertEngineSettings.cs","PerformanceMonitor.Alerting/IAlertReadAdapter.cs","PerformanceMonitor.Alerting/PoisonWaitAccumulation.cs","PerformanceMonitor.Alerting/PoisonWaitDelta.cs","PerformanceMonitor.Alerting/PoisonWaitEvaluator.cs","PerformanceMonitor.Alerting/PostgresAlertEvaluator.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertSeverity.cs","README.md"],"failed_classes":["Darling.Tests.DarlingAlertReadAdapterTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"28b796d7eaa462dbc49c465bb82ed2a80be38b09","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs","Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs","Darling/Darling.Tests/McpPageContractTests.cs","Darling/Darling.Tests/PlanCorrectionFrameLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCorrectionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSessionReader.cs","Lite.Tests/McpPageContractTests.cs","Lite/Mcp/McpAlertTools.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpLongQueryTools.cs","Lite/Mcp/McpPlanCorrectionTools.cs","Lite/Mcp/McpWaitTools.cs","Lite/Services/LocalDataService.AlertHistory.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.LongQueries.cs","Lite/Services/LocalDataService.PlanCorrection.cs","Lite/Services/LocalDataService.WaitStats.cs","Lite/Services/LocalDataService.WaitingTasks.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"ae826023f8efed709f9d67ad1883d46b5d20b2ed","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisCoverageLivePostgresTests.cs","Darling/Darling.Tests/AnalyzeWindowEmptyLivePostgresTests.cs","Darling/Darling.Tests/PgFactCollectorTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/AnalysisCoverageTests.cs","Lite.Tests/AnalysisWindowEmptyTests.cs","Lite.Tests/FactCollectorTests.cs","Lite.Tests/McpMissMessageParityPinTests.cs","Lite.Tests/TestDataSeeder.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DuckDbFactCollector.Waits.cs","Lite/Analysis/DuckDbFactCollector.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/AnalysisContext.cs","PerformanceMonitor.Analysis/FactCollectorHelpers.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FactCollectorTests"],"failed_jobs":["build"]} +{"date":"2026-09-18","head_sha":"88289f02d64a72332dd78bedad3099ae913a1452","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/DarlingMcpCustomAlertToolsTests.cs","Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs","Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs","Darling/Darling.Tests/DarlingMcpToolsTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/McpMuteReportsWhatItMatchedTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/FindingStore.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpInstructions.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FindingStoreTests"],"failed_jobs":["build"]} +{"date":"2026-09-18","head_sha":"250bb914f74e70c43b7ed55438daec146a9a8834","event":"pull_request","changed_files":["Lite.Tests/FactScorerTests.cs","Lite.Tests/ScenarioTests.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactScorer.cs","PerformanceMonitor.Analysis/RelationshipGraph.cs","deprecated/Dashboard.Tests/FactScorerTests.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"30496f2e0d867e83bf63313ce89e573918de8ea3","event":"pull_request","changed_files":["Lite.Tests/SlackDetailsSizeTests.cs","Lite.Tests/SlackProseSplitTests.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs"],"failed_classes":["PerformanceMonitorLite.Tests.LiteDeltaSeederTests"],"failed_jobs":["build"]} +{"date":"2026-09-18","head_sha":"8e71bf0f59a90b98f3c7a5761b74f15182614b91","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingDeltaSeederTests.cs","Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalColumnsRungTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionRungTests.cs","Darling/Darling.Tests/OversizedPlanBacklogPins.cs","Darling/Darling.Tests/PgTrendReaderTests.cs","Darling/Darling.Tests/StartupCommandTimeoutTests.cs","Darling/Darling.Tests/ViewerHistoryWindowTests.cs","Darling/Darling.Tests/ViewerQueriesRestTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingDeltaCalculator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/DeltaFamilyIntervalColumnTests.cs","Lite.Tests/DeltaFamilySeedingCensusTests.cs","Lite.Tests/DeltaFamilyUnknowableRowReadTests.cs","Lite.Tests/DuckDbSchemaEquivalenceTests.cs","Lite.Tests/LiteDeltaSeederTests.cs","Lite.Tests/MemoryGrantsCollectorDefinitionTests.cs","Lite.Tests/PgStatementStatsDeltaSkipTests.cs","Lite.Tests/PgStatementStatsFlavorTests.cs","Lite.Tests/PgWaitStatsCollectorDefinitionTests.cs","Lite.Tests/PgWaitStatsDeltaSkipTests.cs","Lite.Tests/ProcedureStatsCollectorDefinitionTests.cs","Lite.Tests/QueryStatsCollectorDefinitionTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Mcp/McpMemoryTools.cs","Lite/Services/DeltaCalculator.cs","Lite/Services/LocalDataService.MemoryGrants.cs","Lite/Services/LocalDataService.QueryStats.cs","PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs","PerformanceMonitor.Collectors/MemoryGrantsCollector.cs","PerformanceMonitor.Collectors/PgStatementStatsCollector.cs","PerformanceMonitor.Collectors/PgWaitStatsCollector.cs","PerformanceMonitor.Collectors/ProcedureStatsCollector.cs","PerformanceMonitor.Collectors/QueryStatsCollector.cs"],"failed_classes":["Darling.Tests.DarlingMcpMemoryGrantToolsSurfaceAndSqlTests","Darling.Tests.DarlingPerformanceTrendsReadTests","Darling.Tests.DeltaFamilyIntervalCompletionRungTests","Darling.Tests.OversizedPlanBacklogPins","Darling.Tests.PayloadDimensionTests","Darling.Tests.PgSchemaGeneratorTests","PerformanceMonitorLite.Tests.PerformanceTrendsToolTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-18","head_sha":"b9d8e955ac7cfd4b42c00f22a782b975e537b014","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingDeltaSeederTests.cs","Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DarlingPerformanceTrendsReadTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalColumnsRungTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionRungTests.cs","Darling/Darling.Tests/OversizedPlanBacklogPins.cs","Darling/Darling.Tests/PayloadDimensionTests.cs","Darling/Darling.Tests/PgTrendReaderTests.cs","Darling/Darling.Tests/StartupCommandTimeoutTests.cs","Darling/Darling.Tests/ViewerHistoryWindowTests.cs","Darling/Darling.Tests/ViewerQueriesRestTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingDeltaCalculator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/OversizedPlanBacklog.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ItemHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/DeltaFamilyIntervalColumnTests.cs","Lite.Tests/DeltaFamilySeedingCensusTests.cs","Lite.Tests/DeltaFamilyUnknowableRowReadTests.cs","Lite.Tests/DuckDbSchemaEquivalenceTests.cs","Lite.Tests/LiteDeltaSeederTests.cs","Lite.Tests/MemoryGrantsCollectorDefinitionTests.cs","Lite.Tests/PgStatementStatsDeltaSkipTests.cs","Lite.Tests/PgStatementStatsFlavorTests.cs","Lite.Tests/PgWaitStatsCollectorDefinitionTests.cs","Lite.Tests/PgWaitStatsDeltaSkipTests.cs","Lite.Tests/ProcedureStatsCollectorDefinitionTests.cs","Lite.Tests/QueryStatsCollectorDefinitionTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Mcp/McpMemoryTools.cs","Lite/Services/DeltaCalculator.cs","Lite/Services/LocalDataService.MemoryGrants.cs","Lite/Services/LocalDataService.QueryStats.cs","PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs","PerformanceMonitor.Collectors/MemoryGrantsCollector.cs","PerformanceMonitor.Collectors/PgStatementStatsCollector.cs","PerformanceMonitor.Collectors/PgWaitStatsCollector.cs","PerformanceMonitor.Collectors/ProcedureStatsCollector.cs","PerformanceMonitor.Collectors/QueryStatsCollector.cs"],"failed_classes":["PerformanceMonitorLite.Tests.PerformanceTrendsToolTests"],"failed_jobs":["build"]} +{"date":"2026-09-18","head_sha":"51e6bca179f87fddde1a4058cff217152c1bfa53","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingFleetReaderTests.cs","Darling/Darling.Tests/DarlingPgSessionStatesReaderTests.cs","Darling/Darling.Tests/DeadlockRateBandTests.cs","Darling/Darling.Tests/FleetCardPostgresCpuLivePostgresTests.cs","Darling/Darling.Tests/FleetCardPostgresCpuTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/Darling.Tests/UnmeasuredMetricsAreNotHealthyTests.cs","Darling/Darling.Tests/ViewerFleetRollupTests.cs","Darling/Darling.Tests/ViewerW2aTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgSessionStatesReader.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/README.md","PerformanceMonitor.Alerting/PostgresAlertEvaluator.cs","PerformanceMonitor.Common/FleetDeadlockCoverage.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DarlingPgReadSqlParsesLiveTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.ViewerFleetDeadlockCoverageTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-18","head_sha":"d0d482835c7fcb99b9abd7612a2db1179f8f523f","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpHealthToolsTests.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.XminHorizonPersistenceReadTests","Lite.Tests.FindingsRetentionHorizonPinTests","PerformanceMonitorLite.Tests.PerformanceCalendarDataTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-18","head_sha":"f07bfc441fc2287e1a9829881d02d8b373608dfc","event":"pull_request","changed_files":["Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Lite.Tests/DeltaFamilyUnknowableRowReadTests.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests","PerformanceMonitorLite.Tests.McpZeroIsAMeasurementTests","PerformanceMonitorLite.Tests.TrendEmptyParityToolTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-18","head_sha":"6a2f100f08c6e5955cd17986a32fbd09ecc6207e","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpPgLoggingAuditToolsTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLoggingAuditTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPgLoggingAudit.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgLoggingAuditReader.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","docs/postgres-first-target-runbook.md","llms.txt"],"failed_classes":["Darling.Tests.McpLatestSnapshotStampTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"8a800ed6c69065e5d03ea6a2b3609e2f562ffce6","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpPgLoggingAuditToolsTests.cs","Darling/Darling.Tests/McpLatestSnapshotStampTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLoggingAuditTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPgLoggingAudit.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgLoggingAuditReader.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","docs/postgres-first-target-runbook.md","llms.txt"],"failed_classes":["PerformanceMonitorLite.Tests.LiteDeltaSeederTests"],"failed_jobs":["build"]} +{"date":"2026-09-18","head_sha":"95b65cc63310978dd531c30ba1418b6bf4e91621","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorStateContractTests.cs","Darling/Darling.Tests/PgWaitInstrumentDisclosureTests.cs","Darling/Darling.Tests/PgWaitSamplerLiveTests.cs","Darling/Darling.Tests/SweepBodyDetachPolicyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitSamplingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgWaitSamplingReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","Darling/README.md","Lite.Tests/PgWaitExclusionParityTests.cs","Lite.Tests/PgWaitSamplerArmTests.cs","Lite.Tests/PgWaitSamplingCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/CollectorEngineCapability.cs","PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs","PerformanceMonitor.Collectors/CollectorTargetInfo.cs","PerformanceMonitor.Collectors/PgWaitInstrument.cs","PerformanceMonitor.Collectors/PgWaitSamplingCollector.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Darling.Tests.CollectorEngineCapabilityMovingGateTests","Darling.Tests.DarlingCliCommandsTests","Darling.Tests.DarlingComposeTests","Darling.Tests.DocCommentHygieneTests","Lite.Tests.AuroraOnlySqlIsGatedTests","Lite.Tests.DefaultTraceEventsCollectorDefinitionTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-18","head_sha":"9261cb58aa955a32a96d02ee61ef4842f1abca4e","event":"pull_request","changed_files":[".github/workflows/build.yml",".github/workflows/nightly.yml","Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/DarlingPgReadSqlParsesLiveTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionRungTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgSchemaGeneratorTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLogEventTools.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgLogEventReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresTabs.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Darling/README.md","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","Lite.Tests/LogTailOverlapThresholdPinTests.cs","Lite.Tests/PgServerLogPathPinTests.cs","PerformanceMonitor.Collectors/CollectorCatalog.cs","PerformanceMonitor.Collectors/CollectorEngineCapability.cs","PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs","PerformanceMonitor.Collectors/IPgLogFamilyParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEntry.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEvent.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgLogFamilyParsers.cs","PerformanceMonitor.Collectors/PgLogTextRedactor.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Darling.Tests.CollectionSweepCommandTimeoutTests","Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.ReadmeDerivedCountPinTests","Darling.Tests.StorageCommandTimeoutTests","Darling.Tests.StoreCopyPhaseTests","Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-18","head_sha":"87559e0f726889edec0ecaafe49e792058892aae","event":"pull_request","changed_files":["Darling/Darling.Tests/CaptureDownChunkOrderTests.cs","Darling/Darling.Tests/CollectionSignalsChunkOrderTests.cs","Darling/Darling.Tests/IntervalDedupMaterializationIndexesTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"b98b7540a345b3cfd19f1de47b689abe8605735a","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorEngineCapabilityDerivationTests.cs","Darling/Darling.Tests/CollectorStateContractTests.cs","Darling/Darling.Tests/DarlingCliCommandsTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/PgWaitInstrumentDisclosureTests.cs","Darling/Darling.Tests/PgWaitSamplerLiveTests.cs","Darling/Darling.Tests/SweepBodyDetachPolicyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitSamplingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWaitTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgWaitSamplingReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","Darling/README.md","Lite.Tests/PgWaitExclusionParityTests.cs","Lite.Tests/PgWaitSamplerArmTests.cs","Lite.Tests/PgWaitSamplingCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/CollectorEngineCapability.cs","PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs","PerformanceMonitor.Collectors/CollectorTargetInfo.cs","PerformanceMonitor.Collectors/PgWaitInstrument.cs","PerformanceMonitor.Collectors/PgWaitSamplingCollector.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Lite.Tests.AuroraOnlySqlIsGatedTests","Lite.Tests.DefaultTraceEventsCollectorDefinitionTests"],"failed_jobs":["build"]} +{"date":"2026-09-18","head_sha":"a479b4577cabd0ad1e04e8ff2d160e585e48e02d","event":"pull_request","changed_files":["Darling/Darling.Tests/DrillDownDopProvenanceLiveTests.cs","Darling/Darling.Tests/QueryDopProvenanceTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs","Lite.Tests/DrillDownDopProvenanceParityTests.cs","Lite.Tests/DrillDownDopProvenanceTests.cs","Lite.Tests/QueryDopProvenanceTests.cs","Lite/Analysis/DrillDownCollector.Queries.cs","PerformanceMonitor.Common/QueryDopProvenance.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests","Lite.Tests.CrossAppGuardCiGateTests","Lite.Tests.DrillDownDopProvenanceTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-18","head_sha":"5ef0af68f595381e671bb76ba770f6a9990204ea","event":"pull_request","changed_files":["Darling/Darling.Tests/DailyHealthBandTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs","Darling/Darling.Tests/ViewerQueriesRestTests.cs","Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","Darling/PerformanceMonitor.Darling.Storage/DurationTrendRouting.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Pvs.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs","Lite.Tests/DailyHealthBandTests.cs","Lite/Controls/FinOpsTab.xaml.cs","Lite/Mcp/McpPvsTools.cs","Lite/Services/LocalDataService.DailySummary.cs","Lite/Services/LocalDataService.FinOps.Pvs.cs","PerformanceMonitor.Common/DailyHealthBand.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.ViewerQueriesLivePostgresTests","Darling.Tests.ViewerTrendRoutingPortTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-18","head_sha":"5916fab68e84d2cd1f808f7bbea01db0fe1cf49a","event":"pull_request","changed_files":["Darling/Darling.Tests/ForcePlanBotPolicyTests.cs","Darling/Darling.Tests/PlanForceBotTests.cs","Darling/Darling.Tests/StragglerCommandTimeoutTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingForcePlanTargetStateReader.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceBot.cs","Lite.Tests/ForcePlanTargetStateTests.cs","PerformanceMonitor.Analysis/FactRemediation.cs","PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests","PerformanceMonitorLite.Tests.LiteDeltaSeederTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-18","head_sha":"d46e8a24f4b5a0a8e3fa4c4e99a85e67dd129577","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/NotificationRoutesRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/PgLogEventMetricsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertDeliverer.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFindingAlertSender.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/PgNotificationRouteStore.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/NotificationRouteEditDialog.xaml","Darling/PerformanceMonitor.Darling.Viewer/NotificationRouteEditDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Darling/tools/provision-roles.sql","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","Lite/Mcp/McpAlertTools.cs","PerformanceMonitor.Notifications/AlertContext.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/EmailSendCore.cs","PerformanceMonitor.Notifications/IAlertSettings.cs","PerformanceMonitor.Notifications/NotificationRoute.cs","PerformanceMonitor.Notifications/NotificationRouter.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs","README.md","docs/postgres-first-target-runbook.md","llms.txt"],"failed_classes":["Darling.Tests.DarlingWebEndpointsTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-18","head_sha":"7859d25653535bcfaed78bb4e6d8b6081e830983","event":"pull_request","changed_files":["Darling/Darling.Tests/DailyHealthBandTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs","Darling/Darling.Tests/ViewerQueriesRestTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","Darling/PerformanceMonitor.Darling.Storage/DurationTrendRouting.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Pvs.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs","Lite.Tests/DailyHealthBandTests.cs","Lite/Controls/FinOpsTab.xaml.cs","Lite/Mcp/McpPvsTools.cs","Lite/Services/LocalDataService.DailySummary.cs","Lite/Services/LocalDataService.FinOps.Pvs.cs","PerformanceMonitor.Common/DailyHealthBand.cs"],"failed_classes":["PerformanceMonitorLite.Tests.LiteDeltaSeederTests"],"failed_jobs":["build"]} +{"date":"2026-09-19","head_sha":"0db975aa98f5169df1272262f7ffa8a85d09a067","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetPostureIsolationTests.cs","Darling/Darling.Tests/PgTargetPostureTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Posture.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Posture.cs","PerformanceMonitor.Analysis/PgTargetScorer.Posture.cs"],"failed_classes":["Darling.Tests.PgTargetFactCollectorTests","Darling.Tests.PgTargetPostureTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"189380cb25b21bb738f179287500498af60f732c","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetPostureIsolationTests.cs","Darling/Darling.Tests/PgTargetPostureTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Posture.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Posture.cs","PerformanceMonitor.Analysis/PgTargetScorer.Posture.cs"],"failed_classes":["Darling.Tests.PgTargetFactCollectorTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"ea96ebcb37fa58734bf639c690cf26b58061d502","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPgOperationalAlertTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/README.md"],"failed_classes":["Darling.Tests.FleetCardPostgresCpuTests","Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"f84624af0cd09e47fb54a1270fd602a7934594cb","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTempTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.Queries.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Database.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Database.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Temp.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Query.cs","PerformanceMonitor.Analysis/PgTargetScorer.Config.cs","PerformanceMonitor.Analysis/PgTargetScorer.Database.cs","PerformanceMonitor.Analysis/PgTargetScorer.Temp.cs"],"failed_classes":["Darling.Tests.PgTargetTempTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"5971efceff087e63cfc802ee02deb5fdb328a8eb","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/Darling.Tests/PgTargetWaitLiveTests.cs","Darling/Darling.Tests/PgTargetWaitTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Waits.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Wait.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Memory.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Saturation.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Write.cs","PerformanceMonitor.Analysis/PgTargetScorer.Waits.cs"],"failed_classes":["Darling.Tests.PgTargetSessionsTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"de7cedee09f986a7f531b55b7028983db0af9ab9","event":"push","changed_files":["Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs"],"failed_classes":["Darling.Tests.PgTargetSessionsTests","Darling.Tests.PgTargetTempTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"8f038a3b8ad423a5f3d0b42ee6a61332fe1a5b0f","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTempTests.cs"],"failed_classes":["Darling.Tests.PgTargetSessionsTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"b7b434b1a1277f0c62107cfd1b0a983094874896","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetSessionsTests.cs"],"failed_classes":["Darling.Tests.PgTargetTempTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"cc855a652505ca3a243c65b3662cb16154deff9f","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetSessionsTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Cpu.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/Baselines/MetricNames.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Cpu.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetScorer.Cpu.cs"],"failed_classes":["Lite.Tests.AuroraOnlySqlIsGatedTests"],"failed_jobs":["build"]} +{"date":"2026-09-19","head_sha":"f88740972bd0a32119ac76b006a515268c3dad67","event":"push","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetSessionsTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Cpu.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/Baselines/MetricNames.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Cpu.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetScorer.Cpu.cs"],"failed_classes":["Lite.Tests.AuroraOnlySqlIsGatedTests"],"failed_jobs":["build"]} +{"date":"2026-09-19","head_sha":"48855f4ed13c1d5538e7e8f960d0f6729fd91b69","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs","Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs","Darling/Darling.Tests/DarlingMcpToolsTests.cs","Darling/Darling.Tests/McpPlanAnalysisEnvelopeTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMemoryGrantReader.cs","Lite.Tests/FindingStoreTests.cs","Lite.Tests/McpDescriptionTruthPinTests.cs","Lite.Tests/McpMuteReportsWhatItMatchedTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/FindingStore.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpCpuTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpPlanTools.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"6435c010681772587dd7cf77439c3b0961afdb99","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPgAutovacuumVerdictLiveTests.cs","Darling/Darling.Tests/McpFilterSemanticsLivePostgresTests.cs","Darling/Darling.Tests/McpPageContractTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/PgLogEventMetricsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCollectorCostTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgAutovacuumTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIndexUsageTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgSessionStatesTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgTableBloatTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStallProbeTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Lite.Tests/McpPageContractTests.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpHealthTools.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.DarlingMcpHealthToolsLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"c733f086ae7a20170c71d645be6150523cdc598d","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisShutdownResidueTests.cs","Darling/Darling.Tests/DarlingAnalysisStoreTests.cs","Darling/Darling.Tests/RecurrenceLabelStoreReadTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/FindingStoreRecurrenceReadTests.cs","Lite.Tests/RecurrenceLabelerTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/FindingStore.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/RecurrenceLabeler.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FindingStoreTests"],"failed_jobs":["build"]} +{"date":"2026-09-19","head_sha":"5dd751bb19c8c7d08824b3709080362be0e4eff8","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetIoTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Io.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Io.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Io.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Io.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Io.cs","PerformanceMonitor.Analysis/PgTargetScorer.Io.cs"],"failed_classes":["Darling.Tests.PgTargetWaitTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"6f61658ab21bb8aa6c3381bb583ceec45414dc20","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetKnobsTests.cs","Darling/Darling.Tests/PgTargetReplicationTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Replication.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Replication.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Replication.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Replication.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Replication.cs","PerformanceMonitor.Analysis/PgTargetScorer.Replication.cs"],"failed_classes":["Darling.Tests.PgTargetWaitTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"1acd606201b265f20ea5de097d3f7d374e15c841","event":"push","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetKnobsTests.cs","Darling/Darling.Tests/PgTargetReplicationTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Replication.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Replication.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Replication.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Replication.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Replication.cs","PerformanceMonitor.Analysis/PgTargetScorer.Replication.cs"],"failed_classes":["Darling.Tests.PgTargetWaitTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"c3a18e178599f0cb02223b32074ee44168acf5a7","event":"pull_request","changed_files":["Lite.Tests/FindingStoreTests.cs"],"failed_classes":["Darling.Tests.PgTargetWaitTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"6981fdf77c19ccb1be712d61e626d21410b7e516","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorTimestampFrameTests.cs","Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/PgNumbackendsAndSampledMsRungTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/AgedDatabaseMigrationTests.cs","Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs","Lite.Tests/GoldenCollectorSchema.cs","Lite.Tests/PerfmonCounterTypeTests.cs","Lite.Tests/ServerPropertiesCollectorDefinitionTests.cs","Lite.Tests/TimeHonestyRungTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Mcp/McpCpuTools.cs","Lite/Mcp/McpServerInfoTools.cs","Lite/Services/LocalDataService.Cpu.cs","Lite/Services/LocalDataService.ServerInfo.cs","PerformanceMonitor.Collectors/CpuUtilizationCollector.cs","PerformanceMonitor.Collectors/ServerPropertiesCollector.cs"],"failed_classes":["Darling.Tests.PgTargetWaitTests","Lite.Tests.ServerEpochTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-19","head_sha":"a7ecf42814a12b8d60f72e80dedeec7ca0dd4ed2","event":"pull_request","changed_files":["Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.PgTargetWaitTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"3064bb598fd2f708f7d819e2489f9770c85c53ca","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertDeliveryChannelTests.cs","Darling/Darling.Tests/AlertHistoryRowSeverityTests.cs","Darling/Darling.Tests/AlertMetricClassifierTests.cs","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/AlertTrayStatusLoggedTests.cs","Darling/Darling.Tests/AnalysisSinglesDigestTests.cs","Darling/Darling.Tests/DarlingAlertingTests.cs","Darling/Darling.Tests/DarlingAnalysisPipelineTests.cs","Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Service/AnalysisSinglesDigestReader.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFindingAlertSender.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFileLevelAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/PgAlertHistoryStore.cs","Darling/PerformanceMonitor.Darling.Service/SelfAlertDeliveryStampStore.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Viewer/NotificationRouteEditDialog.xaml.cs","Darling/README.md","Lite.Tests/AlertReadFailureSurfaceTests.cs","Lite.Tests/AlertSeverityTests.cs","Lite.Tests/FindingRoutingTests.cs","Lite.Tests/LiteRecommendationsReaderTests.cs","Lite/Analysis/FindingStore.cs","Lite/Analysis/Recommendations/LiteRecommendationItem.cs","Lite/Analysis/Recommendations/LiteRecommendationsReader.cs","Lite/Analysis/Recommendations/LiteRecommendationsViewModel.cs","Lite/App.xaml.cs","Lite/Controls/RecommendationsTab.xaml","Lite/Mcp/McpAlertTools.cs","Lite/Services/AppAlertSettings.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/EmailAlertService.cs","Lite/Windows/SettingsWindow.xaml","Lite/Windows/SettingsWindow.xaml.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Analysis/AnalysisModels.cs","PerformanceMonitor.Analysis/InferenceEngine.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertContext.cs","PerformanceMonitor.Notifications/AlertDelivery.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/AlertSeverity.cs","PerformanceMonitor.Notifications/AnalysisNotificationService.cs","PerformanceMonitor.Notifications/FindingRouting.cs","PerformanceMonitor.Notifications/IAlertHistoryStore.cs","PerformanceMonitor.Notifications/IAlertSettings.cs","PerformanceMonitor.Notifications/IFindingAlertSender.cs","README.md","deprecated/Dashboard/Services/EmailAlertService.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.PgTargetWaitTests","Lite.Tests.AlertSettingsControlWiringTests","PerformanceMonitorLite.Tests.SettingsSampleTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-19","head_sha":"d98145b5f748258ab3efba77c78400cac11fa94e","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorTimestampFrameTests.cs","Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/PgNumbackendsAndSampledMsRungTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/AgedDatabaseMigrationTests.cs","Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs","Lite.Tests/GoldenCollectorSchema.cs","Lite.Tests/PerfmonCounterTypeTests.cs","Lite.Tests/ServerEpochTests.cs","Lite.Tests/ServerPropertiesCollectorDefinitionTests.cs","Lite.Tests/TimeHonestyRungTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Mcp/McpCpuTools.cs","Lite/Mcp/McpServerInfoTools.cs","Lite/Services/LocalDataService.Cpu.cs","Lite/Services/LocalDataService.ServerInfo.cs","PerformanceMonitor.Collectors/CpuUtilizationCollector.cs","PerformanceMonitor.Collectors/ServerPropertiesCollector.cs"],"failed_classes":["Darling.Tests.PgTargetReplicationTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"1c443a9d29f7085884b6aeebb4ba718df8e8089c","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetCollectionHealthMemoTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"533c979f43821b83e5cf909077e25a9ce7873202","event":"push","changed_files":["Darling/Darling.Tests/FleetCollectionHealthMemoTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"d7b4be3582c9052a64ca9fc5cdfe26985c282e4b","event":"pull_request","changed_files":["Darling/Darling.Tests/LocalClockBucketKeyTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs","Lite.Tests/LocalClockBucketKeyTests.cs","Lite.Tests/RobustBaselineDuckDbTests.cs","Lite/Analysis/BaselineProvider.cs","PerformanceMonitor.Analysis/Baselines/BaselineLocalClock.cs"],"failed_classes":["Darling.Tests.FleetIdentifierScrubTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"ff29c3211aa2b7539b0c02a9507ef3e2c1ca8450","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"7d743c64928046f474e39613a3ba88e47b86f5a8","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs","Darling/Darling.Tests/AnalyzeWindowEmptyLivePostgresTests.cs","Darling/Darling.Tests/CollectionCaveatsParityTests.cs","Darling/Darling.Tests/CollectionCaveatsTests.cs","Darling/Darling.Tests/PgTargetCollectionCaveatsLiveTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/AnalysisWindowEmptyTests.cs","Lite.Tests/AnomalyDetectorTests.cs","Lite.Tests/CollectionCaveatsTests.cs","Lite.Tests/McpMissMessageParityPinTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DuckDbFactCollector.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/AnalysisContext.cs"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.PgTargetCollectionCaveatsLiveTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"a755d680a07feb51fe9b669d2ac762441d428bb0","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV2Tests.cs","Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetSampledWaitLiveTests.cs","Darling/Darling.Tests/PgTargetSampledWaitTests.cs","Darling/Darling.Tests/PgTargetWaitTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.WaitsSampled.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.WaitsSampled.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Waits.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","PerformanceMonitor.Analysis/AnomalyIncidentReconciler.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/Baselines/MetricNames.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Wait.cs","PerformanceMonitor.Analysis/PgTargetFactKeys.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetScorer.Waits.cs"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.PgTargetCollectionCaveatsLiveTests","Darling.Tests.PgTargetReplicationTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-19","head_sha":"109542618ff1f021383161dc2c3d3129bf69a54f","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionOutputBesideCostTests.cs","Darling/Darling.Tests/SwallowedItemFailureTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/CollectionOutputBesideCostTests.cs","Lite.Tests/EnumeratedCollectorDriverTests.cs","Lite.Tests/SwallowedItemFailureTests.cs","Lite/Mcp/McpHealthTools.cs","Lite/Services/LocalDataService.CollectionHealth.cs","Lite/Services/RemoteCollectorService.DefinitionRunner.cs","Lite/Services/RemoteCollectorService.LongQueryCompletions.cs","PerformanceMonitor.Collectors/EnumeratedCollectorDriver.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.PgTargetCollectionCaveatsLiveTests","Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"31dd698957f29ee718566360a70042fac5c5b89b","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetCollectionCaveatsLiveTests.cs"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-19","head_sha":"a0f0f4e0e5b2a4f3de410e7f581dc855a1b8c8c1","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsTests.cs","Darling/Darling.Tests/DarlingCollectorToggleVerbLivePostgresTests.cs","Darling/Darling.Tests/StragglerCommandTimeoutTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"d175cf1d707674a1fe11552400573bdfaa483074","event":"pull_request","changed_files":["Darling/Darling.Tests/ConfigChangeTraceAnchorLivePostgresTests.cs","Darling/Darling.Tests/ConfigChangeTraceAnchorTests.cs","Darling/Darling.Tests/ServerLocalReadFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Lite.Tests/ConfigChangeAttributionPipelineTests.cs","Lite.Tests/ConfigChangeAttributionTests.cs","Lite/Analysis/AnalysisService.cs","PerformanceMonitor.Analysis/ConfigChangeAttribution.cs","PerformanceMonitor.Analysis/FactAdvice.cs"],"failed_classes":["Darling.Tests.FactSourceRegistryTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-20","head_sha":"c1df3b08c2e005b4bedb39c5633311aaf2aaaa85","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisAsOfAnchorTests.cs","Darling/Darling.Tests/AsOfWindowAnchorTests.cs","Darling/Darling.Tests/DarlingDailySummaryRangeTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs","Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs","Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs","Darling/Darling.Tests/DarlingMcpHealthToolsTests.cs","Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs","Darling/Darling.Tests/DarlingMcpToolsTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DarlingPeerDisclosureTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/FactSourceRegistryTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/McpFilterSemanticsLivePostgresTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/OversizedPlanSweepRunRecordTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFleetSweepEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgDeadlockTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLogEventTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgServerStateTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Lite.Tests/AnalysisAsOfAnchorTests.cs","Lite.Tests/AsOfWindowAnchorTests.cs","Lite.Tests/DailySummaryRangeToolTests.cs","Lite.Tests/McpMissMessageParityPinTests.cs","Lite.Tests/McpPageContractTests.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpPlanTools.cs","Lite/Mcp/ServerResolver.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.PgLogEventsLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"c939f460ff5fad35f7f1f85bc266a49f769a0502","event":"push","changed_files":["Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs","Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs","Darling/Darling.Tests/DarlingEmptyEnumerationInventoryTests.cs","Darling/Darling.Tests/QueryStoreHealthStoreTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Lite.Tests/EmptyEnumerationInventoryTests.cs","Lite.Tests/QueryStoreHealthCollectorDefinitionTests.cs","Lite/Mcp/McpHealthTools.cs","PerformanceMonitor.Collectors/QueryStoreHealthCollector.cs"],"failed_classes":["Darling.Tests.PgLogEventsLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"d4c281ca1357c631f71d9414081ef33fe8010310","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineDiscontinuityRenderTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/BaselineDiscontinuityReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Discontinuities.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs","Lite.Tests/BaselineDiscontinuityDuckDbTests.cs","Lite.Tests/BaselineDiscontinuityRenderCensusTests.cs","Lite.Tests/BaselineDiscontinuityTests.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Mcp/McpIoTools.cs","Lite/Mcp/McpMemoryTools.cs","Lite/Mcp/McpPerfmonTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Mcp/McpWaitTools.cs","Lite/Services/LocalDataService.Discontinuities.cs","PerformanceMonitor.Collectors/BaselineDiscontinuity.cs","PerformanceMonitor.Collectors/CollectorMeasurement.cs","PerformanceMonitor.Common/ChartPalette.cs","PerformanceMonitor.Ui/ChartStyle.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"8e01ff4d1d8af7b3d176ac08dab58d512f7c6747","event":"push","changed_files":["Darling/Darling.Tests/CollectorTimestampFrameTests.cs","Darling/Darling.Tests/DarlingCollectorRunnerTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs","Lite.Tests/TimeHonestyRungTests.cs","Lite.Tests/WaitStatsCollectorDefinitionTests.cs","Lite/Services/RemoteCollectorService.DefinitionRunner.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/CollectorDefinitionBase.cs","PerformanceMonitor.Collectors/CpuUtilizationCollector.cs","PerformanceMonitor.Collectors/ICollectorDefinition.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"b3e0c82b695d6104116e3099b715e2f436cf37ac","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetSessionsTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Sessions.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Sessions.cs","PerformanceMonitor.Analysis/PgTargetScorer.Sessions.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PgTargetReplicationTests","Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"5e105b8c26ca1336abff8ec3aee83d840ceee1bb","event":"pull_request","changed_files":[".github/workflows/build.yml",".github/workflows/nightly.yml","Darling/Darling.Tests/LongRunningQueryExclusionKnobRungTests.cs","Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs","Darling/Darling.Tests/PgCpuCapacityHeadroomTests.cs","Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/PgNumbackendsAndSampledMsRungTests.cs","Darling/Darling.Tests/PgSchemaGeneratorTests.cs","Darling/Darling.Tests/RdsCpuIngestorTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/Darling.Tests/ViewerCollectorCoverageTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCpuIngestor.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/PgDatabaseSizeStatsCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/CollectorCatalog.cs","PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs","PerformanceMonitor.Collectors/PgCpuUtilizationCollector.cs","PerformanceMonitor.Collectors/PgDatabaseSizeStatsCollector.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.ViewerPostgresTabsTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-20","head_sha":"c88c3941d5b49f220a0c9a47d927c8ee2882ab9a","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetBufferDrillDownTests.cs","Darling/Darling.Tests/PgTargetKnobsTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.Buffer.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Buffer.cs"],"failed_classes":["Darling.Tests.MeasurementContractCensusTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"bea6b41fd3539f97316bb7fea9c2a59d593c23d0","event":"push","changed_files":["Darling/Darling.Tests/PgTargetBufferDrillDownTests.cs","Darling/Darling.Tests/PgTargetKnobsTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.Buffer.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Buffer.cs"],"failed_classes":["Darling.Tests.MeasurementContractCensusTests","Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.TsqlConventionGuardTests","Darling.Tests.ViewerPostgresTabsTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-20","head_sha":"06ec67ccb1efc04b8c3500b41e1f2e3b6153a7ee","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV2Tests.cs","Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetPlanTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/Darling.Tests/PgTargetV3PlumbingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Plans.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Plans.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Plans.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Plans.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Plans.cs","PerformanceMonitor.Analysis/PgTargetScorer.Plans.cs"],"failed_classes":["Darling.Tests.MeasurementContractCensusTests","Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.TsqlConventionGuardTests","Darling.Tests.ViewerPostgresTabsTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"4b8823b72a2b692f631f59ee37afc6663b6b905d","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetBufferDrillDownTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.Buffer.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Buffer.cs"],"failed_classes":["Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.ViewerPostgresTabsTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"1a6b308e238e3e987bc24c04becbabff57510a2b","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAgToolsTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs"],"failed_classes":["Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.TsqlConventionGuardTests","Darling.Tests.ViewerPostgresTabsTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"9d96ad152bef8883ca7628d14af49d47567765c8","event":"pull_request","changed_files":["Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/PgLogEventMetricsTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgRegistryPanelPlacementTests.cs","Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs","Darling/Darling.Tests/ViewerPostgresTabsTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresTabs.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"8aca2617bb4673716b0d17b721f5dcc6a19c6eb1","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV2Tests.cs","Darling/Darling.Tests/PgTargetFactCollectorTests.cs","Darling/Darling.Tests/PgTargetKernelTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/Darling.Tests/PgTargetV3PlumbingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Kernel.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Kernel.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Kernel.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Kernel.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Kernel.cs","PerformanceMonitor.Analysis/PgTargetScorer.Kernel.cs"],"failed_classes":["Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.TsqlConventionGuardTests","Darling.Tests.ViewerPostgresTabsTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"cffafdc423fb4c52210cc3d63931e88e1d653c1d","event":"pull_request","changed_files":["Darling/Darling.Tests/TsqlConventionGuardTests.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"291e15f88bc3571b75e4006b3e239f73f38eb2c5","event":"push","changed_files":["Darling/Darling.Tests/TsqlConventionGuardTests.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"9d82420cda9bff1a3bc500fb94e39e8ae2b71ad1","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetFactCollectorTests.cs","Darling/Darling.Tests/PgTargetKnobsTests.cs","Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetMemoryTests.cs","Darling/Darling.Tests/PgTargetQueriesTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/Darling.Tests/PgTargetV3PlumbingTests.cs","Darling/Darling.Tests/PgTargetVacuumLiveTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Memory.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Memory.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.HostMemory.cs","PerformanceMonitor.Analysis/PgTargetScorer.Memory.cs"],"failed_classes":["Darling.Tests.PgDatabaseSizeStatsAndHostMemoryRungTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-20","head_sha":"e247eea3f0b25e7279d7c692ffe0213b822e789c","event":"pull_request","changed_files":["Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/QsCaptureModeRouteKnobToastRungTests.cs","Darling/Darling.Tests/QueryStoreHealthStoreTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/GoldenCollectorSchema.cs","Lite.Tests/QsCaptureModeRungTests.cs","Lite.Tests/QueryStoreHealthCollectorDefinitionTests.cs","Lite/Database/DuckDbInitializer.cs","PerformanceMonitor.Collectors/QueryStoreHealthCollector.cs"],"failed_classes":["PerformanceMonitorLite.Tests.TimeHonestyRungReadTests"],"failed_jobs":["build"]} +{"date":"2026-09-20","head_sha":"f2e1816f81b68b39752fa4c0c85afe198ee9abf3","event":"push","changed_files":["Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/QsCaptureModeRouteKnobToastRungTests.cs","Darling/Darling.Tests/QueryStoreHealthStoreTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/GoldenCollectorSchema.cs","Lite.Tests/QsCaptureModeRungTests.cs","Lite.Tests/QueryStoreHealthCollectorDefinitionTests.cs","Lite/Database/DuckDbInitializer.cs","PerformanceMonitor.Collectors/QueryStoreHealthCollector.cs"],"failed_classes":["PerformanceMonitorLite.Tests.TimeHonestyRungReadTests"],"failed_jobs":["build"]} +{"date":"2026-09-20","head_sha":"b84ea4c86ca2634c509b3a6e9bebe2f691974e9b","event":"pull_request","changed_files":["CONTRIBUTING.md","Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs","Darling/Darling.Tests/McpPlanAnalysisEnvelopeTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Lite.Tests/McpDescriptionTruthPinTests.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpPlanTools.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactScorer.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","deprecated/Dashboard.Tests/FactScorerTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.TimeHonestyRungReadTests"],"failed_jobs":["build"]} +{"date":"2026-09-20","head_sha":"4f8499a944bf3a542da30625e84a6c99cdd18bf4","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/QueryStoreClutterLivePostgresTests.cs","Darling/Darling.Tests/QueryStoreClutterTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreClutterReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/QueryStoreClutter.cs","Darling/README.md","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.QsCaptureModeRouteKnobToastRungTests","PerformanceMonitorLite.Tests.TimeHonestyRungReadTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-20","head_sha":"20215a40ebee87fc3f85010519633909bcdec0dd","event":"push","changed_files":["CONTRIBUTING.md","Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs","Darling/Darling.Tests/McpPlanAnalysisEnvelopeTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Lite.Tests/McpDescriptionTruthPinTests.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpPlanTools.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactScorer.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","deprecated/Dashboard.Tests/FactScorerTests.cs"],"failed_classes":["Darling.Tests.QsCaptureModeRouteKnobToastRungTests","PerformanceMonitorLite.Tests.TimeHonestyRungReadTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-20","head_sha":"4a3f52389e78a2f0b12110625d80c28a4fb290bb","event":"pull_request","changed_files":["Darling/Darling.Tests/QsCaptureModeRouteKnobToastRungTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.TimeHonestyRungReadTests"],"failed_jobs":["build"]} +{"date":"2026-09-20","head_sha":"35add29023357fdc4f350d96bb6688290a7887c5","event":"pull_request","changed_files":["Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QsCaptureModeRouteKnobToastRungTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Lite.Tests/AlertSeverityTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/AlertSeverity.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-20","head_sha":"87a03565941bff9b73529cc875c17fe771bd724e","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisSinglesDigestTests.cs","Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/LongRunningQueryExclusionKnobRungTests.cs","Darling/Darling.Tests/QsCaptureModeRouteKnobToastRungTests.cs","Darling/Darling.Tests/SelfDiskWarnGbFloorRungTests.cs","Darling/Darling.Tests/UncorroboratedRouteStoreKnobTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFileLevelAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs","Darling/README.md","Lite.Tests/McpAlertSettingsKeyTests.cs","Lite/Mcp/McpAlertTools.cs"],"failed_classes":["Lite.Tests.McpAlertSettingsKeyTests"],"failed_jobs":["build"]} +{"date":"2026-09-20","head_sha":"f2c1294ae3bc617c55568fbafd3d2de89046ff17","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/CollectionCaveatsTests.cs","Darling/Darling.Tests/FactSourceRegistryTests.cs","Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV2Tests.cs","Darling/Darling.Tests/PgTargetCollectionCaveatsLiveTests.cs","Darling/Darling.Tests/PgTargetFactCollectorTests.cs","Darling/Darling.Tests/PgTargetGrowthLiveTests.cs","Darling/Darling.Tests/PgTargetGrowthTests.cs","Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/Baselines/MetricNames.cs","PerformanceMonitor.Analysis/FactScorer.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Growth.cs","PerformanceMonitor.Analysis/PgTargetAdvice.cs","PerformanceMonitor.Analysis/PgTargetFactKeys.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Growth.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetScorer.Growth.cs","PerformanceMonitor.Analysis/PgTargetScorer.cs"],"failed_classes":["Darling.Tests.PgTargetGrowthTests","Darling.Tests.SerialLoopStoreSizeSourceTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-20","head_sha":"129a9265845265a1e02f9632e3f092da0e0606e3","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QsCaptureModeRouteKnobToastRungTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Lite.Tests/AlertSeverityTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/AlertSeverity.cs"],"failed_classes":["Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-20","head_sha":"f19a97d0411b2ca912a067092de521f2364032ed","event":"push","changed_files":["Darling/Darling.Tests/DarlingMcpPgPercentDenominatorTests.cs","Darling/Darling.Tests/DarlingPgDatabaseReaderTests.cs","Darling/Darling.Tests/PgAutovacuumDisabledSpellingLiveTests.cs","Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/PgNumbackendsAndSampledMsRungTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV3Tests.cs","Darling/Darling.Tests/PgTargetBloatTests.cs","Darling/Darling.Tests/PgTargetKernelTests.cs","Darling/Darling.Tests/PgTargetMemoryTests.cs","Darling/Darling.Tests/PgTargetReplicationTests.cs","Darling/Darling.Tests/PgTargetSampledWaitTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/Darling.Tests/PgTargetTempTests.cs","Darling/Darling.Tests/PgTargetV3PlumbingTests.cs","Darling/Darling.Tests/PgTargetVacuumLiveTests.cs","Darling/Darling.Tests/PgTargetVacuumTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Plans.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Plans.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgDatabaseTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgDatabaseReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerPostgresDisplay.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Darling/README.md","Lite.Tests/PgAutovacuumStatsCollectorDefinitionTests.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Bloat.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Vacuum.cs","PerformanceMonitor.Analysis/PgTargetFactKeys.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Query.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Collectors/PgAutovacuumStatsCollector.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-21","head_sha":"477e9ac503c870b827f8c4f22fe11b4fb880f878","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisSinglesDigestTests.cs","Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/LongRunningQueryExclusionKnobRungTests.cs","Darling/Darling.Tests/SelfDiskWarnGbFloorRungTests.cs","Darling/Darling.Tests/UncorroboratedRouteStoreKnobTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFileLevelAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertSettings.cs","Darling/README.md","Lite.Tests/McpAlertSettingsKeyTests.cs","Lite/Mcp/McpAlertTools.cs"],"failed_classes":["Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-21","head_sha":"5c2c7289820c4e4f7ed34296c3f185f69409c21d","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetQueriesTests.cs","Darling/Darling.Tests/PgTargetSeqScanTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Queries.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Queries.cs","PerformanceMonitor.Analysis/PgTargetFactKeys.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Query.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetScorer.Queries.cs"],"failed_classes":["Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-21","head_sha":"948bde86693cbbcd3c1768fbab37a95e9c0c51c5","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/CollectionCaveatsTests.cs","Darling/Darling.Tests/FactSourceRegistryTests.cs","Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV2Tests.cs","Darling/Darling.Tests/PgTargetCollectionCaveatsLiveTests.cs","Darling/Darling.Tests/PgTargetFactCollectorTests.cs","Darling/Darling.Tests/PgTargetGrowthLiveTests.cs","Darling/Darling.Tests/PgTargetGrowthTests.cs","Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/Baselines/MetricNames.cs","PerformanceMonitor.Analysis/FactScorer.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Growth.cs","PerformanceMonitor.Analysis/PgTargetAdvice.cs","PerformanceMonitor.Analysis/PgTargetFactKeys.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Growth.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetScorer.Growth.cs","PerformanceMonitor.Analysis/PgTargetScorer.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests","Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-21","head_sha":"b067397d9de9c2c8894a7d298ae443225929bdb4","event":"push","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/CollectionCaveatsTests.cs","Darling/Darling.Tests/FactSourceRegistryTests.cs","Darling/Darling.Tests/PgDatabaseSizeStatsAndHostMemoryRungTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV2Tests.cs","Darling/Darling.Tests/PgTargetCollectionCaveatsLiveTests.cs","Darling/Darling.Tests/PgTargetFactCollectorTests.cs","Darling/Darling.Tests/PgTargetGrowthLiveTests.cs","Darling/Darling.Tests/PgTargetGrowthTests.cs","Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetSharedSwitchRoutingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Growth.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryHorizon.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs","PerformanceMonitor.Analysis/Baselines/MetricNames.cs","PerformanceMonitor.Analysis/FactScorer.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Growth.cs","PerformanceMonitor.Analysis/PgTargetAdvice.cs","PerformanceMonitor.Analysis/PgTargetFactKeys.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Growth.cs","PerformanceMonitor.Analysis/PgTargetRelationshipGraph.cs","PerformanceMonitor.Analysis/PgTargetScorer.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetScorer.Growth.cs","PerformanceMonitor.Analysis/PgTargetScorer.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests","Lite.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["Darling PostgreSQL tests","build","Darling whole-tree guards"]} +{"date":"2026-09-21","head_sha":"c9b95b611cf73d1b36631a0b623e12fd88e2dc6b","event":"pull_request","changed_files":["Darling/Darling.Tests/PgStatementStatsInfoGateLiveTests.cs","Darling/Darling.Tests/PostgresFaultOutcomeTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/PgStatementStatsFlavorTests.cs","PerformanceMonitor.Collectors/PgExtensionDependency.cs","PerformanceMonitor.Collectors/PgStatementStatsCollector.cs"],"failed_classes":["Darling.Tests.DatabaseMismatchTripwireTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-21","head_sha":"92be623256b979e1fb3910ef375c6254beb702b2","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionOutputBesideCostTests.cs","Darling/Darling.Tests/FleetCollectionHealthMemoTests.cs","Darling/Darling.Tests/RegressedFromProductiveTests.cs","Darling/Darling.Tests/UnmeasuredMetricsAreNotHealthyTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Lite.Tests/RegressedFromProductiveTests.cs","Lite/Mcp/McpHealthTools.cs","Lite/Services/LocalDataService.CollectionHealth.cs","PerformanceMonitor.Collectors/CollectorRuntimePrecondition.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.RepoFileAdoptionTests","Darling.Tests.StoreToastAndCheckpointerLivePostgresTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-21","head_sha":"113ccbc9eb8019e534a8c97a826890e4a3dae18b","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreClutterLivePostgresTests.cs","Darling/Darling.Tests/QueryStoreClutterTests.cs","Darling/Darling.Tests/QueryStoreClutterViewerSurfacesTests.cs","Darling/Darling.Tests/packages.lock.json","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingQueryStoreClutterReader.cs","Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj","Darling/PerformanceMonitor.Darling.Storage/QueryStoreClutter.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Filters.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryStoreClutter.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json","Darling/README.md"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.QueryStoreClutterTests","Darling.Tests.RepoFileAdoptionTests","Darling.Tests.StoreToastAndCheckpointerLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-21","head_sha":"c951ca03081e8f49c0d4f59c4d1967f055a557df","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetBloatTests.cs","Darling/Darling.Tests/PgTargetReplicationTests.cs","Darling/Darling.Tests/PgTargetVacuumLiveTests.cs","Darling/Darling.Tests/PgTargetVacuumTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Vacuum.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Vacuum.cs","PerformanceMonitor.Analysis/PgTargetScorer.Vacuum.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-21","head_sha":"c8bb17645f917253c7540a4d0cb22006256e25d1","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/AlertStoredValueTests.cs","Darling/Darling.Tests/CompressionStuckConfirmReadTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/PolicyJobFamilyTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/README.md","Lite.Tests/AlertHistoryValueFormatTests.cs","Lite.Tests/AlertSeverityTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/AlertSeverity.cs","docs/retention-hold-runbook.md"],"failed_classes":["Darling.Tests.AlertMasterSwitchSurfaceTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.RetentionHoldRatioKnobRungTests","PerformanceMonitorLite.Tests.AlertSeverityTests"],"failed_jobs":["build","Darling PostgreSQL tests","Darling whole-tree guards"]} +{"date":"2026-09-21","head_sha":"55b2d254e5078ae2a5d963d816dd8a345d99be2c","event":"pull_request","changed_files":["Darling/Darling.Tests/RetentionHoldRatioKnobRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs"],"failed_classes":["Darling.Tests.StoreToastAndCheckpointerLivePostgresTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-21","head_sha":"cd34bbcb318f9378a980ca707cba20b3bf58da71","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.StatusBarSizeReadLockTests"],"failed_jobs":["build"]} +{"date":"2026-09-21","head_sha":"914403e3b7d0d351270e592f46ad9b597691af96","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/StorySideLeafTests.cs","Lite/Analysis/FindingStore.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/AnalysisModels.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/InferenceEngine.cs","PerformanceMonitor.Analysis/StorySideLeaves.cs"],"failed_classes":["Darling.Tests.CollectionCaveatsParityTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-21","head_sha":"63c4632be69cda4d374ca7cf3b129bc5f81244ad","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingTriageEndpointTests.cs","Darling/Darling.Tests/packages.lock.json","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj"],"failed_classes":["Darling.Tests.RetentionHoldRatioKnobRungTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-21","head_sha":"965fdfca847fbe3a53f4e0cb5b528d30c36b00e9","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingTriageEndpointTests.cs","Darling/Darling.Tests/packages.lock.json","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darling.Service.csproj"],"failed_classes":["Darling.Tests.RetentionHoldRatioKnobRungTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-21","head_sha":"d03f75494ff29b0c436ca763532ab640abf60177","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreObjectConvergenceTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.CompressionEnableGuardLiveTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-22","head_sha":"6200b3bbe2a0ed5d0ef45217d174462ac543269f","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/AlertReadRetrySeamTests.cs","Darling/Darling.Tests/CaptureDownChunkOrderTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs"],"failed_classes":["Darling.Tests.StartupCommandTimeoutTests","Darling.Tests.TimeHonestyRungTests"],"failed_jobs":["Darling PostgreSQL tests","build"]} +{"date":"2026-09-22","head_sha":"bbeb6db0e4711c3b18954d49e19932b13b8e2339","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionLogStatusFilterTests.cs","Darling/Darling.Tests/DarlingCollectionLogReadTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Lite/Mcp/McpHealthTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Services/LocalDataService.CollectionHealth.cs","PerformanceMonitor.Collectors/EnumeratedCollectorDriver.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.DarlingCollectionLogReadTests"],"failed_jobs":["Darling PostgreSQL tests"]} +{"date":"2026-09-22","head_sha":"5b75c5facd0a15d4a4f961fe8a3bdf5522a18439","event":"pull_request","changed_files":["Darling/Darling.Tests/PgServerConfigOverrideLivePostgresTests.cs","Darling/Darling.Tests/PgServerConfigScopeRungTests.cs","Darling/Darling.Tests/QsCaptureModeRouteKnobToastRungTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.Clock.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Blocking.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Config.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Memory.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Posture.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgServerStateTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgLoggingAuditReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgServerConfigReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/PgServerConfigCollector.cs"],"failed_classes":["Darling.Tests.PgServerConfigScopeRungTests"],"failed_jobs":["build","Darling PostgreSQL tests"]} +{"date":"2026-09-22","head_sha":"d3e2cfc9c915e124c09fcdf77a96e0418fdc6853","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/JobHistoryIdentityEpochTests.cs","PerformanceMonitor.Collectors/JobHistoryCollector.cs"],"failed_classes":["PerformanceMonitorLite.Tests.MeasurementContractCensusTests"],"failed_jobs":["build"]} +{"date":"2026-09-22","head_sha":"3200d274e802ebf3aed589282463748b4c765fc0","event":"pull_request","changed_files":[".github/workflows/build.yml","Lite.Tests/CrossAppGuardCiGateTests.cs"],"failed_classes":["Lite.Tests.WatermarkPolicyTests"],"failed_jobs":["Lite tests (3)"]} +{"date":"2026-09-22","head_sha":"d81ac503b46e7ec59a9f9c3f5fb9a201c250066b","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthAggregateTests.cs","Darling/Darling.Tests/FleetCollectionHealthMemoTests.cs","Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","PerformanceMonitor.Collectors/CollectorRuntimePrecondition.cs","PerformanceMonitor.Collectors/EnumeratedCollectorDriver.cs"],"failed_classes":["Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.TimescaleAggregateCompressionTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-22","head_sha":"2b2e634561cbdcd94aa80a013d0f19e4e52670a9","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthAggregateTests.cs","Darling/Darling.Tests/FleetCollectionHealthMemoTests.cs","Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/StoreObjectConvergenceTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","PerformanceMonitor.Collectors/CollectorRuntimePrecondition.cs","PerformanceMonitor.Collectors/EnumeratedCollectorDriver.cs"],"failed_classes":["Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.RetentionReevaluationLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-22","head_sha":"ae851b1a26f31e1c93508a756b066d479ee9bd28","event":"pull_request","changed_files":["Darling/Darling.Tests/HoldEarnedByDeliveryTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.AlertDeliveryModeThreadingGuardTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-22","head_sha":"8ae98e8094848169747dba32a0587c3973eb355d","event":"pull_request","changed_files":["Lite.Tests/FindingStoreTests.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-22","head_sha":"d99e7f9f94b8b1f211debfaaa61747be494b3df1","event":"pull_request","changed_files":["Lite.Tests/FindingStoreTests.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-22","head_sha":"9f4761741f9976124b8890709f0490a75380415d","event":"push","changed_files":["Darling/Darling.Tests/ComposeStatementTimeoutReloadTests.cs","Darling/Darling.Tests/ComposeStatementTimeoutStoreTests.cs","Darling/Darling.Tests/ControlPlaneReloadDurabilityTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/NotificationRoutesRungTests.cs","Darling/Darling.Tests/ProvisioningTestSecrets.cs","Darling/Darling.Tests/ScramSha256VerifierTests.cs","Darling/Darling.Tests/ScramVerifierLiveTests.cs","Darling/Darling.Tests/StartupCommandTimeoutTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/ScramSha256Verifier.cs","Darling/PerformanceMonitor.Darling.Storage/StoreStatementStats.cs","Darling/README.md","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.McpFilterSemanticsLivePostgresTests","PerformanceMonitorLite.Tests.McpPageContractTests"],"failed_jobs":["Lite tests (3)","Darling PG tests (2)"]} +{"date":"2026-09-22","head_sha":"1792370ebd4f2d9a5a7874b3410fee69ab599d96","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs","Darling/Darling.Tests/StoreMetricsSummaryFirstTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs"],"failed_classes":["PerformanceMonitorLite.Tests.McpPageContractTests"],"failed_jobs":["Lite tests (3)"]} +{"date":"2026-09-23","head_sha":"7a98743705b31928a967d72f2b88c91a10a6b431","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingPgRuntimeVersionPinTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeTests.cs","Darling/Darling.Tests/StartupCommandTimeoutTests.cs","Darling/Darling.Tests/StoreTimescaleAlertTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/README.md","Darling/tools/check-runtime-currency.ps1","Darling/tools/fetch-pg-runtime.ps1"],"failed_classes":["Darling.Tests.CaptureDownChunkOrderTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"a498a4ad8e1dc179dd73a28e020bf461f9c10688","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingPgRuntimeVersionPinTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeTests.cs","Darling/Darling.Tests/StartupCommandTimeoutTests.cs","Darling/Darling.Tests/StoreTimescaleAlertTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/README.md","Darling/tools/check-runtime-currency.ps1","Darling/tools/fetch-pg-runtime.ps1"],"failed_classes":["Darling.Tests.CaptureDownChunkOrderTests","Darling.Tests.CollectionHealthAggregateTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"1e52a8e34fc2dfddaed20c1ac718951efdacb818","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingLockWaitTrendTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DarlingPerformanceTrendsReadTests.cs","Darling/Darling.Tests/DarlingQueryHeatmapTests.cs","Darling/Darling.Tests/DarlingTrendEmptyTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpZeroIsAMeasurementTests.cs","Darling/Darling.Tests/TrendBucketingLiveTests.cs","Darling/Darling.Tests/TrendBucketsTests.cs","Darling/Darling.Tests/TrendPayloadBudgetLiveTests.cs","Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/DurationTrendRouting.cs","Darling/PerformanceMonitor.Darling.Storage/TrendBucketSql.cs","Darling/README.md","Lite.Tests/LockWaitTrendToolTests.cs","Lite.Tests/McpMissMessageParityPinTests.cs","Lite.Tests/PerformanceTrendsToolTests.cs","Lite.Tests/QueryHeatmapToolTests.cs","Lite.Tests/TrendBucketingToolTests.cs","Lite.Tests/TrendEmptyParityToolTests.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpIoTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.TrendBuckets.cs","PerformanceMonitor.Common/Mcp/TrendBuckets.cs","PerformanceMonitor.Common/Mcp/TrendPayloads.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Darling.Tests.LatestValueLookbackLivePostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-23","head_sha":"28c0765ab125cebd227815ace7599970e8dd77b6","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingLockWaitTrendTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DarlingPerformanceTrendsReadTests.cs","Darling/Darling.Tests/DarlingQueryHeatmapTests.cs","Darling/Darling.Tests/DarlingTrendEmptyTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpZeroIsAMeasurementTests.cs","Darling/Darling.Tests/TrendBucketingLiveTests.cs","Darling/Darling.Tests/TrendBucketsTests.cs","Darling/Darling.Tests/TrendPayloadBudgetLiveTests.cs","Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/DurationTrendRouting.cs","Darling/PerformanceMonitor.Darling.Storage/TrendBucketSql.cs","Darling/README.md","Lite.Tests/LockWaitTrendToolTests.cs","Lite.Tests/McpMissMessageParityPinTests.cs","Lite.Tests/PerformanceTrendsToolTests.cs","Lite.Tests/QueryHeatmapToolTests.cs","Lite.Tests/TrendBucketingToolTests.cs","Lite.Tests/TrendEmptyParityToolTests.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpIoTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.TrendBuckets.cs","PerformanceMonitor.Common/Mcp/TrendBuckets.cs","PerformanceMonitor.Common/Mcp/TrendPayloads.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Darling.Tests.LatestValueLookbackLivePostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-23","head_sha":"eb4c4173c312575d7a8b99912eb7f878427aebf3","event":"pull_request","changed_files":["Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/MaterializationHoleScanShapeTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs"],"failed_classes":["Darling.Tests.CollectionHealthAggregateTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"19e4ce66f252c454e14bf2562fb9bf16f0fc1751","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs","Darling/Darling.Tests/PgTableTuningTests.cs","Darling/Darling.Tests/StoreMetricsLatestSkipScanLiveTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.cs"],"failed_classes":["Darling.Tests.StoreMetricsSummaryFirstLivePostgresTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"145ae76294a6c11c49715f71887fa116b02ea8c6","event":"push","changed_files":["Darling/Darling.Tests/LatestValueLookbackTests.cs","Darling/Darling.Tests/StoreConfigProviderTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs","Darling/PerformanceMonitor.Darling.Analysis/PgLatestValueBounds.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/LatestValueLookbackTests.cs","Lite.Tests/OnLoadRecaptureScheduleTests.cs","Lite/Analysis/DuckDbFactCollector.Config.cs","Lite/Analysis/LatestValueBounds.cs","Lite/Services/ScheduleManager.cs","PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs"],"failed_classes":["PerformanceMonitorLite.Tests.EntraDeviceCodeTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-23","head_sha":"138ddd4992ac3b6d94d45f990e18d5f95f241672","event":"pull_request","changed_files":["Darling/Darling.Tests/PgDeadlockLogTimezoneTests.cs","Darling/Darling.Tests/PgDeadlockNormalizationTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgTargetDeadlockDrillDownTests.cs","Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.Deadlocks.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgDeadlockTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgDeadlockReader.cs","Darling/README.md","Lite.Tests/PgDeadlockLogParserTests.cs","Lite.Tests/PgLoggingCollectorGateTests.cs","PerformanceMonitor.Collectors/PgDeadlockLogParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogTextRedactor.cs","docs/postgres-first-target-runbook.md"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-23","head_sha":"f43c826d2ba28bedb2e33bd881aff8baaf6ea69f","event":"pull_request","changed_files":["Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Lite.Tests/PgPlanCaptureCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs"],"failed_classes":["Lite.Tests.PgPlanLogParserTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-23","head_sha":"32239fd3498a1fa9ce1cc1cce057f21c04f5221a","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/PgDeadlockRemaskTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgDeadlockRemask.cs","Darling/README.md"],"failed_classes":["PerformanceMonitorLite.Tests.MemoryTrendGrantJoinToolTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-23","head_sha":"960a4458e220afbb19990a2929b6c8c1f72201e8","event":"push","changed_files":["Darling/Darling.Tests/FleetIdentifierScrubTests.cs","Darling/Darling.Tests/PgLogEventMetricsTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgLogHashKeyTests.cs","Darling/Dockerfile","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingLogHashKeyFile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLogEventTools.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgLogEventReader.cs","Darling/README.md","Darling/compose/docker-compose.yml","Lite.Tests/AuroraOnlySqlIsGatedTests.cs","Lite.Tests/PgServerLogPathPinTests.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PgLogEvent.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgLogHashKey.cs","PerformanceMonitor.Collectors/PgLogTextRedactor.cs"],"failed_classes":["PerformanceMonitorLite.Tests.MemoryTrendGrantJoinToolTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-23","head_sha":"bb2f5cd5194c69dbff41a63114fb670516bc3062","event":"pull_request","changed_files":["Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgLogTailStderrSiblingLiveTests.cs","Darling/Darling.Tests/PgLoggingCollectorOffTests.cs","Darling/Darling.Tests/RdsLogSourceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogSource.cs","Lite.Tests/PgLoggingCollectorGateTests.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgNoStderrLogFileException.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.CollectionLogDrainForensicsStoreTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-23","head_sha":"00fcf92e55a07ac47ded88666423c2633c92c1e7","event":"pull_request","changed_files":["Darling/Darling.Tests/AbandonedRunEraInvariantReadTests.cs","Darling/Darling.Tests/CollectionOutputBesideCostTests.cs","Darling/Darling.Tests/CollectorHealthClassifierTests.cs","Darling/Darling.Tests/ProductiveZeroBandingTests.cs","Darling/Darling.Tests/RegressedFromProductiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Lite.Tests/CollectionOutputBesideCostTests.cs","Lite.Tests/CollectorHealthClassifierTests.cs","Lite.Tests/RegressedFromProductiveTests.cs","Lite/Services/LocalDataService.CollectionHealth.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.ViewerDailyHealthLivePostgresTests","Darling.Tests.ViewerDailyHealthRowTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)","Darling PG tests (2)"]} +{"date":"2026-09-23","head_sha":"145dd4df66e41917b7183ce89402f7244e03ee1a","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/README.md","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-23","head_sha":"68361e125b316082d7c8b001001046233e0b2381","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/README.md","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-23","head_sha":"4fdaebdec47a70e41dc46a40b73146e9e5606741","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/PgDeadlockRemaskTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgDeadlockRemask.cs","Darling/README.md","PerformanceMonitor.Collectors/PgLogHashKey.cs","PerformanceMonitor.Notifications/AnalysisNotificationService.cs","PerformanceMonitor.Notifications/PerformanceMonitor.Notifications.csproj"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-23","head_sha":"21634fced037b669ab0c3928de1423c73786d243","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/README.md","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-23","head_sha":"2d6ed6393b55ea5b7e58fb53682dadb145991ad8","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolGuideTests.cs","Darling/Darling.Tests/McpToolsListBudget.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/McpUnknownArgumentGuardTests.cs","Darling/Darling.Tests/McpZeroIsAMeasurementTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpToolGuideTools.cs","Lite.Tests/McpDescriptionTruthPinTests.cs","Lite.Tests/McpSchemaCompatTests.cs","Lite.Tests/McpToolGuideTests.cs","Lite.Tests/McpToolsListBudget.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpHealthParserTools.cs","Lite/Mcp/McpHostService.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpToolGuideTools.cs","PerformanceMonitor.Common/Mcp/McpSchemaCompat.cs","PerformanceMonitor.Common/Mcp/McpToolGuide.cs","PerformanceMonitor.Common/Mcp/McpToolGuideTopics.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.DarlingWebEndpointsTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-09-23","head_sha":"a7abf470ffac68b086108acf3623ac038dff5a9f","event":"pull_request","changed_files":["Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsIngestOutcome.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PgBinaryTailText.cs","PerformanceMonitor.Collectors/PgDeadlockLogParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgReadBinaryFileCapability.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.StragglerCommandTimeoutTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","build"]} +{"date":"2026-09-23","head_sha":"0619bcb5c2f14074a43287996b5f3442c56b7042","event":"pull_request","changed_files":["Darling/Darling.Tests/PgBinaryTailTextTests.cs","Darling/Darling.Tests/PgReadBinaryFileAdvisoryTests.cs","Darling/Darling.Tests/PgReadBinaryFileCapabilityTests.cs","Darling/Darling.Tests/PgServerLogTailBinaryRouteTests.cs","Darling/Darling.Tests/PostgresFaultOutcomeTests.cs","Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/Darling.Tests/RdsLogEventIngestorTimezoneTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsIngestOutcome.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/README.md","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PgBinaryTailText.cs","PerformanceMonitor.Collectors/PgDeadlockLogParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgReadBinaryFileAdvisory.cs","PerformanceMonitor.Collectors/PgReadBinaryFileCapability.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs","docs/postgres-first-target-runbook.md","tools/pg-verification-rig/README.md"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PgReadBinaryFileCapabilityTests","Darling.Tests.StragglerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-23","head_sha":"11d7e8b947a81061534dd88975d5be26942afa60","event":"pull_request","changed_files":["Darling/Darling.Tests/PgBinaryTailTextTests.cs","Darling/Darling.Tests/PgReadBinaryFileAdvisoryTests.cs","Darling/Darling.Tests/PgReadBinaryFileCapabilityLiveTests.cs","Darling/Darling.Tests/PgReadBinaryFileCapabilityTests.cs","Darling/Darling.Tests/PgReadBinaryFileStaticsCollection.cs","Darling/Darling.Tests/PgServerLogTailBinaryRouteTests.cs","Darling/Darling.Tests/PostgresFaultOutcomeTests.cs","Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/Darling.Tests/RdsLogEventIngestorTimezoneTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsIngestOutcome.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/README.md","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PgBinaryTailText.cs","PerformanceMonitor.Collectors/PgDeadlockLogParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgReadBinaryFileAdvisory.cs","PerformanceMonitor.Collectors/PgReadBinaryFileCapability.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs","docs/postgres-first-target-runbook.md","tools/pg-verification-rig/README.md"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-23","head_sha":"8c00707258b71d93b652eac9c336338d56945330","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingFileSecurityTests.cs","Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/Darling.Tests/DarlingServiceInstallLocationTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingInstallLocation.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/README.md","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1","docs/retention-hold-runbook.md","docs/uat-onboarding.md"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-23","head_sha":"f2d14d628270342a21ef2571edb4dfd824335d97","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingFileSecurityTests.cs","Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/Darling.Tests/DarlingServiceInstallLocationTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingInstallLocation.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/README.md","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1","docs/retention-hold-runbook.md","docs/uat-onboarding.md"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-23","head_sha":"b7136a99088b383ba9657251bc6537ad6f95483c","event":"pull_request","changed_files":["Darling/Darling.Tests/DocCommentHygieneTests.cs","Darling/Darling.Tests/PgBinaryTailTextTests.cs","Darling/Darling.Tests/PgReadBinaryFileAdvisoryTests.cs","Darling/Darling.Tests/PgReadBinaryFileCapabilityLiveTests.cs","Darling/Darling.Tests/PgReadBinaryFileCapabilityTests.cs","Darling/Darling.Tests/PgReadBinaryFileStaticsCollection.cs","Darling/Darling.Tests/PgServerLogTailBinaryRouteTests.cs","Darling/Darling.Tests/PostgresFaultOutcomeTests.cs","Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/Darling.Tests/RdsLogEventIngestorTimezoneTests.cs","Darling/Darling.Tests/RdsLogTimezoneReadLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsIngestOutcome.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/README.md","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PgBinaryTailText.cs","PerformanceMonitor.Collectors/PgDeadlockLogParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgReadBinaryFileAdvisory.cs","PerformanceMonitor.Collectors/PgReadBinaryFileCapability.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs","docs/postgres-first-target-runbook.md","tools/pg-verification-rig/README.md"],"failed_classes":["Darling.Tests.StoreCopyPhaseTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"ff0f436e5993a76f70f93b4019e51a8f398a6cd5","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/QueryStoreDedupReadTests.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStore.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests","Lite.Tests.McpToolsListBudgetTests"],"failed_jobs":["build","Lite tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"2843f3dd08e9fa9735304a3cfafc39df6e8e8e34","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/QueryStoreDedupReadTests.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStore.cs"],"failed_classes":["Darling.Tests.McpPayloadContractCensusTests","Darling.Tests.McpToolsListBudgetTests","Lite.Tests.McpToolsListBudgetTests"],"failed_jobs":["Darling PG tests (1)","build","Lite tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-23","head_sha":"fbc27f9ad9898ebeab4a95917fe9f37a3a1b4b34","event":"pull_request","changed_files":["Darling/Darling.Tests/McpToolGuideHeads.Alerting.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAlertTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Lite.Tests/McpToolGuideHeads.Alerting.cs","Lite.Tests/McpToolsListBudget/McpAlertTools.txt","Lite/Mcp/McpAlertTools.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"777cd9801fa5a285be9c37000cc0bb02a077ab6b","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/McpFilterSemanticsLivePostgresTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/QueryStoreDedupReadTests.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.QueryStore.cs"],"failed_classes":["Darling.Tests.ConsumedTimestampFrameDisciplineTests"],"failed_jobs":["Darling PG tests (2)","build","Darling PG tests (0)"]} +{"date":"2026-09-23","head_sha":"2c1085b073a01d7174c61941e92c44a202622114","event":"pull_request","changed_files":["Darling/Darling.Tests/McpToolGuideHeads.SqlCore.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/McpToolGuideHeads.SqlCore.cs","Lite.Tests/McpToolsListBudget/McpAnalysisTools.txt","Lite/Mcp/McpAnalysisTools.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-23","head_sha":"135eb2acca3f541867f7d0540f8b77b9e56f9d9f","event":"pull_request","changed_files":["PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs"],"failed_classes":["Darling.Tests.PgLogEventsPipelineTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-24","head_sha":"dbfce0d99588ce3f06c13eb1cdbc580c540d6504","event":"pull_request","changed_files":["Darling/Darling.Tests/CheckpointsTimedRungTests.cs","Darling/Darling.Tests/PostmasterStartTimeRungTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.PostmasterStartTimeLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"5acfbb1a34f6bd5f22f838c027cb0348d116c23c","event":"pull_request","changed_files":["Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-24","head_sha":"3c9d27c4f78f132fcf82ed76eb0fa8679a668f94","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs","Darling/Darling.Tests/McpToolGuideHeads.SqlTail.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpConfigHistoryTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDefaultTraceTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs","Lite.Tests/McpToolGuideHeads.SqlTail.cs","Lite.Tests/McpToolsListBudget/McpConfigTools.txt","Lite.Tests/McpToolsListBudget/McpDefaultTraceTools.txt","Lite.Tests/QueryStoreHealthCaptureModePublishTests.cs","Lite/Mcp/McpConfigTools.cs","Lite/Mcp/McpDefaultTraceTools.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"e673e9f52070516096ddf0723d31ac8af1e806b3","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"d3b1815702e2d143e43a84980ecc14b6825bdb9d","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"1e7169a66f70a75766b6a1a6c9200d71277b9540","event":"pull_request","changed_files":["Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanCaptureLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs"],"failed_classes":["Darling.Tests.CollectorMeasurementSeamTests","Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"8ab99c7b585b446016432a950efe3d8adea6b6fa","event":"pull_request","changed_files":["Darling/Darling.Tests/McpToolGuideHeads.SqlTail.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpMemoryGrantTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs","Lite.Tests/McpToolGuideHeads.SqlTail.cs","Lite.Tests/McpToolsListBudget/McpMemoryTools.txt","Lite/Mcp/McpMemoryTools.cs","PerformanceMonitor.Common/Mcp/McpToolGuideTopics.SqlTail.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"8afd015dbdccf1c9027e6e9e17c61763c898f690","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"a4583d1d4830ffa76bd459a9e0a5ac746bee2242","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingInstallLocationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"ffa1aecf27a2142633e0a4f120468a984f761559","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpMemoryGrantToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs","Lite.Tests/McpLatestSnapshotStampTests.cs","Lite/Mcp/McpMemoryTools.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"353f632c1a31ebeaa4f2879f864b9bab9b8ca526","event":"pull_request","changed_files":["Darling/Darling.Tests/McpToolGuideHeads.SqlCore.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Lite.Tests/McpToolGuideHeads.SqlCore.cs","Lite.Tests/McpToolsListBudget/McpSessionTools.txt","Lite/Mcp/McpSessionTools.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"5d8a9513ee1c55441b5abd8dd4b5f76f71c5f88d","event":"pull_request","changed_files":["Darling/Darling.Tests/DocCommentHygieneTests.cs","Darling/Darling.Tests/PgBinaryTailTextTests.cs","Darling/Darling.Tests/PgReadBinaryFileAdvisoryTests.cs","Darling/Darling.Tests/PgReadBinaryFileCapabilityLiveTests.cs","Darling/Darling.Tests/PgReadBinaryFileCapabilityTests.cs","Darling/Darling.Tests/PgServerEncodingTests.cs","Darling/Darling.Tests/PostgresFaultOutcomeTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/README.md","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PgBinaryTailText.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgReadBinaryFileCapability.cs","PerformanceMonitor.Collectors/PgServerEncoding.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"5a3c5d14c60d24580ab6e34b6c85c1c0ad722b02","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingInstallLocationTests.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"00f09a2d81441c8fd7487706a3b09d94006b8c6b","event":"pull_request","changed_files":["Darling/Darling.Tests/McpToolGuideHeads.AnalyzeQueryPlan.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPlanTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Lite.Tests/McpToolGuideHeads.AnalyzeQueryPlan.cs","Lite.Tests/McpToolsListBudget/McpPlanTools.txt","Lite/Mcp/McpPlanTools.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-24","head_sha":"11729239b1e7fc70215a9f62d3464009e6df403c","event":"pull_request","changed_files":["Darling/Darling.Tests/McpToolGuideHeads.Spinlock.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLatchSpinlockTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs","Lite.Tests/McpToolGuideHeads.Spinlock.cs","Lite.Tests/McpToolsListBudget/McpLatchSpinlockTools.txt","Lite/Mcp/McpLatchSpinlockTools.cs"],"failed_classes":["Darling.Tests.XminHorizonPersistenceReadTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"f1f71ae3be3edc170789d37ce11e3b0aa57798fa","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogSource.cs"],"failed_classes":["Darling.Tests.RdsLogEventIngestorCsvlogTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-24","head_sha":"6ec704afa9efb30b9f0212ca2bede57e4c325be4","event":"push","changed_files":["Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/Darling.Tests/RdsLogEventIngestorCsvlogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCsvlogCarry.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs"],"failed_classes":["Darling.Tests.XminHorizonPersistenceReadTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"a52f09cda3931d58f0554998ec503bb6283c292e","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCoreToolProfileTests.cs","Darling/Darling.Tests/DarlingMcpLatchSpinlockToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLatchSpinlockReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Analysis/FactAdvice.cs","deprecated/Dashboard.Tests/FactAdviceCorrectnessTests.cs","deprecated/Dashboard/Mcp/McpAnalysisTools.cs","deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.LatchSpinlock.cs"],"failed_classes":["Darling.Tests.DarlingMcpLatchSpinlockToolsLivePostgresTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"841ea4e9ddbe22fec0ac39cb093aa5b102a11f3c","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs","PerformanceMonitor.Analysis/AnomalyGate.cs","PerformanceMonitor.Analysis/Baselines/AnomalyThresholds.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"c462a969c7c91725d0a917834f659fa1a0d20620","event":"pull_request","changed_files":["Darling/Darling.Tests/PgDeadlockRaiseShapedUnitTests.cs","Darling/Darling.Tests/PgLogEntryProvenanceTests.cs","Darling/Darling.Tests/PgPlanCaptureRaiseShapedUnitTests.cs","Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsIngestOutcome.cs","PerformanceMonitor.Collectors/PerformanceMonitor.Collectors.csproj","PerformanceMonitor.Collectors/PgDeadlockLogParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEntry.cs","PerformanceMonitor.Collectors/PgLogEntryProvenance.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgServerLogCsvParser.cs","PerformanceMonitor.Collectors/PgServerLogJsonParser.cs"],"failed_classes":["Darling.Tests.CollectorMeasurementSeamTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"77e66c96d20994a2c689d018babc0976a5f972a6","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerCollectionCaveatsTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml"],"failed_classes":["Darling.Tests.ViewerCollectionCaveatsLivePostgresTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"a751513e14339d2cc0da9b3caa5dc6c0e5b6bcf4","event":"pull_request","changed_files":["Darling/Darling.Tests/PgDeadlockRaiseShapedUnitTests.cs","Darling/Darling.Tests/PgLogEntryProvenanceTests.cs","Darling/Darling.Tests/PgPlanCaptureRaiseShapedUnitTests.cs","Darling/Darling.Tests/PgRaiseShapedRecordsLiveTests.cs","Darling/Darling.Tests/RdsDeadlockIngestorTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsIngestOutcome.cs","PerformanceMonitor.Collectors/PerformanceMonitor.Collectors.csproj","PerformanceMonitor.Collectors/PgDeadlockLogParser.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEntry.cs","PerformanceMonitor.Collectors/PgLogEntryProvenance.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgServerLogCsvParser.cs","PerformanceMonitor.Collectors/PgServerLogJsonParser.cs"],"failed_classes":["Darling.Tests.CollectorMeasurementSeamTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"bcaaf9b190968038afb0ddd9d7fd34f8eee499bd","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerCollectionCaveatsTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml"],"failed_classes":["Darling.Tests.ViewerCollectionCaveatsLivePostgresTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"62ae815c1d4a107ede861f351f78b8ea8c1bb82c","event":"pull_request","changed_files":[".github/workflows/build.yml"],"failed_classes":["Darling.Tests.CiClusterWorkerSizingTests"],"failed_jobs":["Darling PG tests (2)","build","Darling whole-tree guards"]} +{"date":"2026-09-24","head_sha":"7fc674875a3b3872a41a063586ca347cedb4b3e9","event":"pull_request","changed_files":["Darling/Darling.Tests/LocalClockBucketKeyTests.cs","Lite.Tests/LocalClockBucketKeyTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-24","head_sha":"22e3803f2785d9938741bd5564988525663e88c2","event":"pull_request","changed_files":["Darling/Darling.Tests/LocalClockBucketKeyTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs","Lite.Tests/LocalClockBucketKeyTests.cs","Lite/Analysis/BaselineProvider.cs"],"failed_classes":["Darling.Tests.ComposeStoreRolesLiveTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Lite tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"ad89ad83a7cee8cec5acca50ccc166a483ad0778","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Blocking.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Io.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Replication.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests","Darling.Tests.PgTargetBetweenWavesV2Tests","Darling.Tests.PgTargetBlockingTests","Darling.Tests.PgTargetIoTests","Darling.Tests.SharedBaselineCacheLiveTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Darling PG tests (1)","build","Darling PG tests (0)","Darling PG tests (2)","Lite tests (2)"]} +{"date":"2026-09-24","head_sha":"08ca596e6508ab4eabb6ff21676dd2717b17f52c","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.PgTargetAnomalyTests","Darling.Tests.PgTargetSampledWaitTests","Darling.Tests.PgTargetSessionsTests","Darling.Tests.SharedBaselineCacheLiveTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)","build","Lite tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"d064b8ebc5d52997af9850e5c2b65e553fe1d658","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["build","Darling PG tests (0)","Lite tests (2)"]} +{"date":"2026-09-24","head_sha":"1e9c8ce764c676d7697a99e97f0e40f5bf9f6755","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV2Tests.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests","Darling.Tests.PgTargetIoTests","Darling.Tests.SharedBaselineCacheLiveTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)","Lite tests (2)"]} +{"date":"2026-09-24","head_sha":"95a42dab2dc799da7f0b90eb642cfd8485a89b80","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.PgTargetAnomalyTests","Darling.Tests.PgTargetSessionsTests","Darling.Tests.SharedBaselineCacheLiveTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","Darling PG tests (2)","build","Lite tests (2)"]} +{"date":"2026-09-24","head_sha":"52b659cee2a30f34aff7f87cae1bedce1889df3d","event":"pull_request","changed_files":["Lite/Analysis/AnomalyDetector.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.AnomalyDetectorTests"],"failed_jobs":["build","Lite tests (2)"]} +{"date":"2026-09-24","head_sha":"f7f0dee46734b4b5742825a8e2820fa188c005c8","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs"],"failed_classes":["Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"3a7e6cf3b725bf5fcaf07de58ba69b3d9d2e3ecf","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetBlockingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Blocking.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Io.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Replication.cs"],"failed_classes":["Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"7b696d60d733692d9e112c142c7968fd9b4bed50","event":"pull_request","changed_files":["Darling/Darling.Tests/SqlServerStoreTileBehaviourTests.cs"],"failed_classes":["Darling.Tests.SqlServerStoreTileBehaviourTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-24","head_sha":"8a87e96d71d3a967230414b7488289dfb5d474b3","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTileBehaviourTests.cs"],"failed_classes":["Darling.Tests.PgTargetTileBehaviourTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"bae1fca91eddf3083311bff3832b6f2b1ad51d8a","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTileBehaviourTests.cs"],"failed_classes":["Darling.Tests.PgTargetTileBehaviourTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"f36b0a75bb9599862d2846bbc5072b175e6c5426","event":"pull_request","changed_files":["Lite/Analysis/AnomalyDetector.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.AnomalyDetectorTests"],"failed_jobs":["build","Lite tests (2)"]} +{"date":"2026-09-24","head_sha":"8983c978418166784cb4607871fb52dedecadbc5","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs"],"failed_classes":["Darling.Tests.SqlServerStoreTileBehaviourTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-24","head_sha":"b3fa71b1b1ca081bc7c053f1caa0d9af71f8784f","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTileBehaviourIoReplicationBlockingTests.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests","Darling.Tests.PgTargetTileBehaviourIoReplicationBlockingTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"26e2db9b66d04d61cb96b744fbf0f8f7368fd3fa","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTileBehaviourTests.cs"],"failed_classes":["Darling.Tests.PgTargetTileBehaviourTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"48510777740fdb6e10d206efbbbd7a4160e6c267","event":"pull_request","changed_files":["Darling/Darling.Tests/SqlServerStoreTileBehaviourTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs"],"failed_classes":["Darling.Tests.SqlServerStoreTileBehaviourTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-24","head_sha":"a3b574993759cf702066c8ddb7f1041bc7ce9ea1","event":"pull_request","changed_files":["Lite.Tests/AnomalyDetectorTests.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"213fe55c693b683c943f3844742fa2258f74bcb8","event":"pull_request","changed_files":["Lite/Analysis/AnomalyDetector.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"0c58c0eb56ea475c91ed9106a639e87602af7595","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetWriteTests.cs"],"failed_classes":["Darling.Tests.PgTargetSampledWaitLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"58d19e6748962ec878106c31b5f0ca9862975abb","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTileBehaviourIoReplicationBlockingTests.cs"],"failed_classes":["Darling.Tests.PgTargetTileBehaviourIoReplicationBlockingTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"798c28746bbb7cf0c9bd0dcea1ce6497f9691e61","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetTileBehaviourTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs"],"failed_classes":["Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"8b3c5767f081393cbc89be5253529987ca309826","event":"pull_request","changed_files":["Darling/Darling.Tests/AnomalyNullWindowLiveTests.cs"],"failed_classes":["Darling.Tests.SqlServerStoreTileBehaviourTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"34b9395198b3bf8fc9913ade1de3ec83876879cd","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetTileBehaviourWalKernelSampledTests.cs"],"failed_classes":["Darling.Tests.PgTargetSampledWaitLiveTests","Darling.Tests.PgTargetTileBehaviourWalKernelSampledTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"c9877aad46cbffd4294ecafb4df9350999b4ce86","event":"pull_request","changed_files":["Lite.Tests/AnomalyDetectorTileBehaviourBatchSessionQueryMemoryTests.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourBatchSessionQueryMemoryTests"],"failed_jobs":["Lite tests (1)","build"]} +{"date":"2026-09-24","head_sha":"fee46d2ffca92f0a5019bd64ccd47be5cc883d73","event":"pull_request","changed_files":["Darling/Darling.Tests/SqlServerStoreTileBehaviourBatchQueryMemoryTests.cs"],"failed_classes":["Darling.Tests.SqlServerStoreTileBehaviourBatchQueryMemoryTests","Darling.Tests.SqlServerStoreTileBehaviourTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"88751883379b8ccf015aa947acc09b2ee65f5ceb","event":"pull_request","changed_files":["Lite.Tests/AnomalyDetectorTileBehaviourTests.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests"],"failed_jobs":["Lite tests (1)","build"]} +{"date":"2026-09-24","head_sha":"4195e827d1fb73d5e31a8918681324d2319282e8","event":"pull_request","changed_files":["Darling/Darling.Tests/RobustBaselineTests.cs","PerformanceMonitor.Analysis/Baselines/BaselineMath.cs"],"failed_classes":["Darling.Tests.LocalClockBucketKeyLiveTests","PerformanceMonitorLite.Tests.BaselineProviderTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Lite tests (2)","Lite tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"d5370df470c7f7f3a589ea9861886bf80a78c561","event":"pull_request","changed_files":["Lite.Tests/AnomalyDetectorTileBehaviourTests.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests"],"failed_jobs":["build","Lite tests (1)"]} +{"date":"2026-09-24","head_sha":"87836cc97e8847b8b59dd6b127b76ad83b4824b0","event":"pull_request","changed_files":["Lite.Tests/AnomalyDetectorTileBehaviourBatchSessionQueryMemoryTests.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourBatchSessionQueryMemoryTests"],"failed_jobs":["build","Lite tests (1)"]} +{"date":"2026-09-24","head_sha":"097fd767dc72abf524df5c1d205a70ab524bacaf","event":"pull_request","changed_files":["Darling/Darling.Tests/SqlServerStoreTileBehaviourBatchQueryMemoryTests.cs"],"failed_classes":["Darling.Tests.SqlServerStoreTileBehaviourTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (2)"]} +{"date":"2026-09-24","head_sha":"3de285b652943777797c221fdbfe1e7a5772a363","event":"pull_request","changed_files":["Lite.Tests/AnomalyDetectorTileBehaviourBatchSessionQueryMemoryTests.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests"],"failed_jobs":["build"]} +{"date":"2026-09-24","head_sha":"91a978ed6f837fa335f79e3f2ff42c590a6c6288","event":"pull_request","changed_files":["Darling/Darling.Tests/RobustBaselineTests.cs","PerformanceMonitor.Analysis/Baselines/BaselineBucket.cs","PerformanceMonitor.Analysis/Baselines/BaselineMath.cs"],"failed_classes":["Darling.Tests.LocalClockBucketKeyLiveTests","PerformanceMonitorLite.Tests.BaselineProviderTests","PerformanceMonitorLite.Tests.LocalClockBucketKeyTests"],"failed_jobs":["Darling PG tests (1)","Lite tests (2)","Lite tests (0)"]} +{"date":"2026-09-24","head_sha":"0ac14fc78e1c18418a0d47eb2a5c67897815d054","event":"pull_request","changed_files":["Lite.Tests/AnomalyDetectorTileBehaviourTests.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests"],"failed_jobs":["build","Lite tests (1)"]} +{"date":"2026-09-24","head_sha":"723a7007c5becb3cc98a1da72dfee0fdddec6710","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetKernelTests.cs","Darling/Darling.Tests/PgTargetSampledWaitLiveTests.cs","Darling/Darling.Tests/PgTargetSampledWaitTests.cs","Darling/Darling.Tests/PgTargetTileBehaviourWalKernelSampledTests.cs","Darling/Darling.Tests/PgTargetWriteTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Kernel.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.WaitsSampled.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.Wal.cs"],"failed_classes":["Darling.Tests.PgTargetSampledWaitLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"94038080ae2080c1760d65597022ec178a36659a","event":"pull_request","changed_files":["Darling/Darling.Tests/RobustBaselineTests.cs","Lite.Tests/BaselineProviderTests.cs","Lite.Tests/LocalClockBucketKeyTests.cs","PerformanceMonitor.Analysis/Baselines/BaselineBucket.cs","PerformanceMonitor.Analysis/Baselines/BaselineMath.cs"],"failed_classes":["Darling.Tests.LocalClockBucketKeyLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"c42df0c5f92bc9c71732755bcc34860e443c9386","event":"pull_request","changed_files":["Darling/Darling.Tests/AnomalyDetectorErrorGuardLiveTests.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-24","head_sha":"80dc86e8553f65af6c8d1030c365c00e9208e40e","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs","Lite.Tests/AnomalyDetectorTests.cs","Lite.Tests/AnomalyDetectorTileBehaviourTests.cs","Lite/Analysis/AnomalyDetector.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"3216c5d674f8f0733675f1aee0fb2662ef863119","event":"pull_request","changed_files":["Darling/Darling.Tests/AnomalyDetectorErrorGuardLiveTests.cs"],"failed_classes":["Darling.Tests.AnomalyDetectorErrorGuardLiveTests","Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-24","head_sha":"5219f29479a942b0568d800782a9c0ff0d56f94d","event":"pull_request","changed_files":["Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","docs/runbooks/a6-successor-daily-backfill.md"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.MaterializationHoleRepairTests","Darling.Tests.TimescaleAggregateCompressionTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)","Lite tests (1)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"e4cb4a3699d102a64a616d5346156e3d39764a81","event":"pull_request","changed_files":["Darling/Darling.Tests/StitchedRelationSqlTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests"],"failed_jobs":["Lite tests (1)","build","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"1c726d7f675026f839843485382c1b5bb974b384","event":"pull_request","changed_files":["Darling/Darling.Tests/SuccessorDailyLiveTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.MaterializationHoleRepairTests","Darling.Tests.SuccessorDailyLiveTests","PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-24","head_sha":"24d76b370d163d39fbb5cb79b4693e1124d47102","event":"pull_request","changed_files":["Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/SuccessorDailyLiveTests.cs"],"failed_classes":["Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.SuccessorDailyLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"31bbb6682d58ef75b32339d642344336b40d7142","event":"pull_request","changed_files":["Darling/Darling.Tests/RollupCoverageRoutingTests.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-24","head_sha":"55d78cb0a2d130b5e0f35f39b0fbad87d6a90698","event":"pull_request","changed_files":["Darling/Darling.Tests/SuccessorDailyLiveTests.cs"],"failed_classes":["Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.SuccessorDailyLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"e708093a4bf102da9a40992e979cb1764a63b55c","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs"],"failed_classes":["Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"37fcfc144c3569b300cc000851b939ccb631317e","event":"pull_request","changed_files":["Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs"],"failed_classes":["Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)","build"]} +{"date":"2026-09-24","head_sha":"d244949e4b4f31ffaa45256b2e78ff4f5a4c6ac5","event":"pull_request","changed_files":["Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/SuccessorDailyLiveTests.cs"],"failed_classes":["Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.SuccessorDailyLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"8454c3afda07704780bec760f9e0a35e9cec706a","event":"pull_request","changed_files":["Darling/Darling.Tests/DailySummaryAndComposeStitchedLiveTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DarlingTrendReaderStitchTests.cs","Darling/Darling.Tests/RollupCoverageRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs"],"failed_classes":["Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PgTargetBlockingTests","Darling.Tests.RollupCoverageRoutingTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-24","head_sha":"e2e007f88c7aae5d0aa6d20c2d3539b2f04e18fe","event":"pull_request","changed_files":["Darling/Darling.Tests/DailySummaryAndComposeStitchedLiveTests.cs","Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DarlingTrendReaderStitchTests.cs","Darling/Darling.Tests/RollupCoverageRoutingTests.cs","Darling/Darling.Tests/StitchedRelationSqlTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs"],"failed_classes":["Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["Darling PG tests (2)","build","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"f17b6df1341f112c110b9600e1b45d5508fc9d2c","event":"pull_request","changed_files":["Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs","Darling/Darling.Tests/RollupCoverageRoutingTests.cs","Darling/Darling.Tests/StitchedRelationSqlTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"5fe4fb64bd23645cbf413d598fd84125baa2367c","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs","Darling/Darling.Tests/SqlServerStoreTileBehaviourBatchQueryMemoryTests.cs","Darling/Darling.Tests/SqlServerStoreTileBehaviourTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs"],"failed_classes":["Darling.Tests.SqlServerStoreTileBehaviourTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"02a1f124d3068b36428d29131cbc875eaa833f6b","event":"pull_request","changed_files":["Darling/Darling.Tests/RollupBackfillTests.cs","Darling/PerformanceMonitor.Darling.Storage/RollupBackfill.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.BaselineSupplyTests","Darling.Tests.CompressionPhaseAssignmentTests","Darling.Tests.DailySummaryAndComposeStitchedLiveTests","Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.DailySummaryNotCarriedTests","Darling.Tests.DailySummaryReadShapeLiveTests","Darling.Tests.DailySummaryReadShapeSqlTests","Darling.Tests.DailySummaryStitchedRangeTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.IntervalHonestHourlyRollupTests","Darling.Tests.MaterializationHoleRepairLiveTests","Darling.Tests.MaterializationHoleRepairTests","Darling.Tests.MaterializationHoleScanShapeLiveTests","Darling.Tests.MeasurementContractCensusTests","Darling.Tests.RefreshCeilingProvenancePinTests","Darling.Tests.RefreshCeilingStalenessTests","Darling.Tests.RollupBackfillLiveTests","Darling.Tests.SuccessorDailyLiveTests","Darling.Tests.TimescaleAggregateCompressionTests","Darling.Tests.TimescaleContinuousAggregateTests","Darling.Tests.TimescaleSupportTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"193506c3bfde145aec380549766e9ac6a38b320d","event":"push","changed_files":["Darling/Darling.Tests/DailyStitchLiveTests.cs","Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs","Darling/Darling.Tests/RollupCoverageRoutingTests.cs","Darling/Darling.Tests/StitchedRelationSqlTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs"],"failed_classes":["Darling.Tests.ComposeStoreRolesLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"1cc0bb25188617cb0e06004cc54819dec6c29f16","event":"pull_request","changed_files":["Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/RollupBackfillTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/PerformanceMonitor.Darling.Storage/RollupBackfill.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.BaselineSupplyTests","Darling.Tests.CompressionPhaseAssignmentTests","Darling.Tests.DailyStitchLiveTests","Darling.Tests.DailySummaryAndComposeStitchedLiveTests","Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.DailySummaryNotCarriedTests","Darling.Tests.DailySummaryReadShapeLiveTests","Darling.Tests.DailySummaryReadShapeSqlTests","Darling.Tests.DailySummaryStitchedRangeTests","Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.IntervalHonestHourlyRollupTests","Darling.Tests.MaterializationHoleRepairLiveTests","Darling.Tests.MaterializationHoleRepairTests","Darling.Tests.MaterializationHoleScanShapeLiveTests","Darling.Tests.MeasurementContractCensusTests","Darling.Tests.PayloadDimensionLiveTests","Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.RefreshCeilingProvenancePinTests","Darling.Tests.RefreshCeilingStalenessTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.RollupBackfillLiveTests","Darling.Tests.SuccessorDailyLiveTests","Darling.Tests.TimescaleAggregateCompressionTests","Darling.Tests.TimescaleContinuousAggregateTests","Darling.Tests.TimescaleSupportTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"1abc48ba8aad34e3ebd80e9116a8c5f725075d0f","event":"pull_request","changed_files":["Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/RollupBackfillTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/PerformanceMonitor.Darling.Storage/RollupBackfill.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.BaselineSupplyTests","Darling.Tests.CompressionPhaseAssignmentTests","Darling.Tests.DailyStitchLiveTests","Darling.Tests.DailySummaryAndComposeStitchedLiveTests","Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.DailySummaryNotCarriedTests","Darling.Tests.DailySummaryReadShapeLiveTests","Darling.Tests.DailySummaryReadShapeSqlTests","Darling.Tests.DailySummaryStitchedRangeTests","Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.IntervalHonestHourlyRollupTests","Darling.Tests.MaterializationHoleRepairLiveTests","Darling.Tests.MaterializationHoleRepairTests","Darling.Tests.MaterializationHoleScanShapeLiveTests","Darling.Tests.MeasurementContractCensusTests","Darling.Tests.PayloadDimensionLiveTests","Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.RefreshCeilingProvenancePinTests","Darling.Tests.RefreshCeilingStalenessTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.RollupBackfillLiveTests","Darling.Tests.SuccessorDailyLiveTests","Darling.Tests.TimescaleAggregateCompressionTests","Darling.Tests.TimescaleContinuousAggregateTests","Darling.Tests.TimescaleSupportTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"fafcda881f46b53d1ba34f479ab8620571b14d89","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/CompressionPhaseAssignmentTests.cs","Darling/Darling.Tests/DailySummaryNotCarriedTests.cs","Darling/Darling.Tests/DailySummaryReadShapeTests.cs","Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/MaterializationHoleScanShapeTests.cs","Darling/Darling.Tests/MeasurementContractCensusTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/RollupBackfillTests.cs","Darling/Darling.Tests/SuccessorDailyLiveTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Storage/RollupBackfill.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.MaterializationHoleScanShapeLiveTests","Darling.Tests.PayloadDimensionLiveTests","Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.RefreshCeilingProvenancePinTests","Darling.Tests.RefreshCeilingStalenessTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.RollupBackfillLiveTests","Darling.Tests.TimescaleContinuousAggregateTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)","build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"775d4b660fcdf3a9140f9553b90a3329f8437eff","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Lite/Mcp/McpHealthTools.cs","Lite/Mcp/McpHostService.cs","Lite/Services/LocalDataService.CollectionHealth.cs","PerformanceMonitor.Common/Mcp/McpResponseBudget.cs","PerformanceMonitor.Common/Mcp/McpResponseBudgetCallToolFilter.cs"],"failed_classes":["Darling.Tests.DarlingCustomViewsTests","Darling.Tests.McpPayloadContractCensusTests","Darling.Tests.McpToolGuideHeadsCollectionLogTests","Darling.Tests.McpToolGuideHeadsPlatformTests","Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"1c2cd71fbd573fb0c6820439316fc4959bb68d6a","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetBetweenWavesV3Tests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreRegressionReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DuckDbFactCollector.cs","Lite/Mcp/McpAnalysisTools.cs","PerformanceMonitor.Common/Mcp/TrendBuckets.cs"],"failed_classes":["Darling.Tests.CollectorRunnerConnectionEngineTests","Darling.Tests.DarlingAnalysisServiceEngineRoutingTests","Darling.Tests.DarlingQueryStoreRegressionsLiveTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.McpToolGuideHeadsQsRegressionsTests","Darling.Tests.McpToolGuideTests","Darling.Tests.McpToolsListBudgetTests","Darling.Tests.PgTargetAuditConfigTests","Darling.Tests.PgTargetMcpSurfaceTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (2)","build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"056523f02308cc435c8d9c9991757d13d3e3e872","event":"push","changed_files":["Darling/Darling.Tests/AnomalyObjectStatsLatestSnapshotsLiveTests.cs","Darling/Darling.Tests/CollectionCaveatsRungTests.cs","Darling/Darling.Tests/IndexObjectStatsServerTimeIndexRungTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/ViewerCollectionCaveatsTests.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"5b9486a0ea03068a45eb8a2b5878c9599674aaca","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingWebHostGateLiveTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/WebFetchLayerTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js"],"failed_classes":["Darling.Tests.WebFetchLayerTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"209c58c87202f9baec11b90e3b6fbbcacfffcd89","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorRunnerConnectionEngineTests.cs","Darling/Darling.Tests/DarlingAnalysisServiceEngineRoutingTests.cs","Darling/Darling.Tests/McpToolGuideHeads.PgA.cs","Darling/Darling.Tests/McpToolGuideHeads.QsRegressions.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgCpuUtilizationTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV3Tests.cs","Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreRegressionReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs","Lite.Tests/McpToolGuideHeads.QsRegressions.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/QueryStoreRegressionsToolTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DuckDbFactCollector.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStoreRegressions.cs","PerformanceMonitor.Common/Mcp/TrendBuckets.cs"],"failed_classes":["Darling.Tests.DarlingQueryStoreRegressionsLiveTests","Darling.Tests.PgTargetAuditConfigTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"a12e72ff28a0ad8795853cb0473f6119c8552836","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/McpFleetResponseBudgetLivePostgresTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpFleetTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Lite.Tests/CollectionLogFleetFormToolTests.cs","Lite.Tests/McpToolsListBudget/McpHealthTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpHealthTools.cs","Lite/Mcp/McpHostService.cs","Lite/Services/LocalDataService.CollectionHealth.cs","PerformanceMonitor.Common/Mcp/McpResponseBudget.cs"],"failed_classes":["Darling.Tests.McpFleetResponseBudgetLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"3fe9f11bd58c18735e57d627c054fcc08b2ca0f7","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorRunnerConnectionEngineTests.cs","Darling/Darling.Tests/DarlingAnalysisServiceEngineRoutingTests.cs","Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs","Darling/Darling.Tests/McpToolGuideHeads.PgA.cs","Darling/Darling.Tests/McpToolGuideHeads.QsRegressions.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgCpuUtilizationTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV3Tests.cs","Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs","Darling/Darling.Tests/TrendPayloadBudgetLiveTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreRegressionReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs","Lite.Tests/McpToolGuideHeads.QsRegressions.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/QueryStoreRegressionsToolTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DuckDbFactCollector.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStoreRegressions.cs","PerformanceMonitor.Common/Mcp/TrendBuckets.cs"],"failed_classes":["Darling.Tests.PgTargetAuditConfigTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"cdc50a6af87bc64b8d2aab0d5b179bc2ed294ca3","event":"pull_request","changed_files":["Darling/Darling.Tests/EventWindowedReadsCarryTheFloorTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSystemHealthReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs"],"failed_classes":["Darling.Tests.ViewerMemorySqlTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"e96652de96310cabffa1e50a30b5f24e70651d79","event":"pull_request","changed_files":["Darling/Darling.Tests/FreshStoreWatermarkTests.cs","Darling/Darling.Tests/OverviewCollectionHealthRollupRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/CollectionHealthRollupSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests","Darling.Tests.ViewerW2aLivePostgresTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"3f02b9209412dd59425de092a7d220362fdd30eb","event":"push","changed_files":["Darling/Darling.Tests/DarlingWebHostGateLiveTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/WebFetchLayerTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js"],"failed_classes":["Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"1038b642ab74798a7f505a824ea94cade1a2c1a2","event":"pull_request","changed_files":["Darling/Darling.Tests/FreshStoreWatermarkTests.cs","Darling/Darling.Tests/OverviewCollectionHealthRollupRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/CollectionHealthRollupSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs"],"failed_classes":["Darling.Tests.ViewerW2aLivePostgresTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"7eefba3376903fbe5ebbf0a53cea4fe6a5e15dc5","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorRunnerConnectionEngineTests.cs","Darling/Darling.Tests/DarlingAnalysisServiceEngineRoutingTests.cs","Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs","Darling/Darling.Tests/McpToolGuideHeads.PgA.cs","Darling/Darling.Tests/McpToolGuideHeads.QsRegressions.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgCpuUtilizationTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV3Tests.cs","Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs","Darling/Darling.Tests/TrendPayloadBudgetLiveTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreRegressionReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs","Lite.Tests/McpToolGuideHeads.QsRegressions.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/QueryStoreRegressionsToolTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DuckDbFactCollector.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStoreRegressions.cs","PerformanceMonitor.Common/Mcp/TrendBuckets.cs"],"failed_classes":["Darling.Tests.AnalysisFactsReadRunsDetectorParityTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"3cb7665ad83d101737b47cbbaac20b7261059d27","event":"pull_request","changed_files":["Darling/Darling.Tests/RetentionTierRouterTests.cs","Darling/Darling.Tests/ViewerFinOpsTests.cs","Darling/Darling.Tests/ViewerFleetTimerFanOutPositionTests.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs"],"failed_classes":["Darling.Tests.RollupCoverageRoutingTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"be157a6d8866aeea321b6a6909eff84265736c4f","event":"pull_request","changed_files":["Darling/Darling.Tests/ServerInventoryFleetMetricsTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs"],"failed_classes":["Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"78450e6cfde3cc9e65014b97b2f7370874f6b8cf","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetCollectionHealthByServerLiveEqualityTests.cs","Darling/Darling.Tests/FreshStoreWatermarkTests.cs","Darling/Darling.Tests/OverviewCollectionHealthRollupRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/CollectionHealthRollupSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs","Lite.Tests/PermissionDeniedBadgeMemoTests.cs","Lite/Services/LocalDataService.CollectionHealth.cs"],"failed_classes":["Darling.Tests.ViewerW2aLivePostgresTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"479aab4000d65569d9074485006a6c4aaffb33f7","event":"pull_request","changed_files":["Darling/Darling.Tests/FinOpsMergedReadsLiveTests.cs","Darling/Darling.Tests/RetentionTierRouterTests.cs","Darling/Darling.Tests/ViewerFinOpsTests.cs","Darling/Darling.Tests/ViewerFleetTimerFanOutPositionTests.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs"],"failed_classes":["Darling.Tests.RollupCoverageRoutingTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"6223c4f0b6203e6ac6ce0b710dcb67935d3bf5b2","event":"pull_request","changed_files":["Darling/Darling.Tests/ServerInventoryFleetMetricsLiveTests.cs","Darling/Darling.Tests/ServerInventoryFleetMetricsTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs"],"failed_classes":["Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"f84ea5dee6d6fe4969656be5631dbc4538c6b90a","event":"pull_request","changed_files":["Darling/Darling.Tests/McpReadToolBudgetLiveTests.cs"],"failed_classes":["Darling.Tests.McpReadToolBudgetLiveTests","Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"b8aff1c3bb0760fcc821dd75aad6d70101b03f2a","event":"pull_request","changed_files":["Darling/Darling.Tests/FinOpsMergedReadsLiveTests.cs","Darling/Darling.Tests/RetentionTierRouterTests.cs","Darling/Darling.Tests/ViewerFinOpsTests.cs","Darling/Darling.Tests/ViewerFleetTimerFanOutPositionTests.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Storage.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs"],"failed_classes":["Darling.Tests.RollupCoverageRoutingTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"692cbb337a5fd5ed044dd862d28cae0b9a8aabf5","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetCollectionHealthByServerLiveEqualityTests.cs","Darling/Darling.Tests/FreshStoreWatermarkTests.cs","Darling/Darling.Tests/OverviewCollectionHealthRollupRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/CollectionHealthRollupSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs","Lite.Tests/PermissionDeniedBadgeMemoTests.cs","Lite/Services/LocalDataService.CollectionHealth.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"fc12b2248e9e233e20eb678f86fa5a259b798ed5","event":"pull_request","changed_files":["Darling/Darling.Tests/AvailabilityGroupCountReadTests.cs","Darling/Darling.Tests/DarlingWebHostGateLiveTests.cs","Darling/Darling.Tests/DarlingWebResponseCompressionTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js"],"failed_classes":["Darling.Tests.DarlingWebResponseCompressionTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"80fa0748bd0ae617c4551844bfb42e1eb632415b","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs","Darling/Darling.Tests/CollectorRunnerConnectionEngineTests.cs","Darling/Darling.Tests/DarlingAnalysisServiceEngineRoutingTests.cs","Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs","Darling/Darling.Tests/McpToolGuideHeads.PgA.cs","Darling/Darling.Tests/McpToolGuideHeads.QsRegressions.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPgCpuUtilizationTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/PgTargetBetweenWavesV3Tests.cs","Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs","Darling/Darling.Tests/TrendPayloadBudgetLiveTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreRegressionReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgCpuUtilizationReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs","Lite.Tests/McpToolGuideHeads.QsRegressions.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/QueryStoreRegressionsToolTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DuckDbFactCollector.cs","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryStoreRegressions.cs","PerformanceMonitor.Common/Mcp/TrendBuckets.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests","Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"43aba6068f8b417e44ff734cc07993ab999e2a6c","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionCaveatsRungTests.cs","Darling/Darling.Tests/DarlingAnalysisPipelineTests.cs","Darling/Darling.Tests/ForcePlanBotPolicyTests.cs","Darling/Darling.Tests/PgFactCollectorTests.cs","Darling/Darling.Tests/PlanRegressionIntervalTableEquivalenceTests.cs","Darling/Darling.Tests/PlanRegressionReadShapeTests.cs","Darling/Darling.Tests/PostmasterStartTimeRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalLatestRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalLatestWriterTests.cs","Darling/Darling.Tests/ViewerCollectionCaveatsTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalLatest.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite/Analysis/DrillDownCollector.Queries.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","PerformanceMonitor.Analysis/AnalysisContext.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactRemediation.cs","PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs","PerformanceMonitor.Analysis/RemediationAction.cs","PerformanceMonitor.Collectors/CollectorContext.cs"],"failed_classes":["Darling.Tests.IndexObjectStatsServerTimeIndexRungTests","Darling.Tests.QueryStoreIntervalLatestRungTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"731512799e340b31b29316f793a4f3ffe304e413","event":"push","changed_files":["Darling/Darling.Tests/CollectionCaveatsRungTests.cs","Darling/Darling.Tests/DarlingAnalysisPipelineTests.cs","Darling/Darling.Tests/ForcePlanBotPolicyTests.cs","Darling/Darling.Tests/IndexObjectStatsServerTimeIndexRungTests.cs","Darling/Darling.Tests/PgFactCollectorTests.cs","Darling/Darling.Tests/PlanRegressionIntervalTableEquivalenceTests.cs","Darling/Darling.Tests/PlanRegressionReadShapeTests.cs","Darling/Darling.Tests/PostmasterStartTimeRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalLatestRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalLatestWriterTests.cs","Darling/Darling.Tests/ViewerCollectionCaveatsTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalLatest.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite/Analysis/DrillDownCollector.Queries.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","PerformanceMonitor.Analysis/AnalysisContext.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactRemediation.cs","PerformanceMonitor.Analysis/ForcePlanBotPolicy.cs","PerformanceMonitor.Analysis/RemediationAction.cs","PerformanceMonitor.Collectors/CollectorContext.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"a91c85be55e83c4e844506f65e8a568ed30dbf56","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpObjectStatsToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpObjectStatsTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/ObjectLockingBudgetLiveTests.cs","Darling/Darling.Tests/ObjectLockingWebDefaultTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Lite.Tests/McpToolsListBudget/McpObjectStatsTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/ObjectLockingBudgetTests.cs","Lite/Mcp/McpObjectStatsTools.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"f421016dd70f658739a10087aa87cfb9069f9430","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingQueryHeatmapBudgetLiveTests.cs","Darling/Darling.Tests/DarlingQueryHeatmapTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryHeatmapTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/QueryHeatmapBudgetTests.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryHeatmap.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"857988aca48247d128673e5c35739f0b865a8754","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs","Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Lite.Tests/McpPageContractTests.cs","Lite.Tests/McpToolsListBudget/McpSessionTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpSessionTools.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"89478c39d22ca3837bfd33eebda345998b88ad41","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingQueryHeatmapBudgetLiveTests.cs","Darling/Darling.Tests/DarlingQueryHeatmapTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryHeatmapTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/QueryHeatmapBudgetTests.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryHeatmap.cs"],"failed_classes":["Darling.Tests.McpPayloadContractCensusTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"b0b0b85e4ac609c54c3496f4bf615b0e7d72cc5e","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs","Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Lite.Tests/McpPageContractTests.cs","Lite.Tests/McpToolsListBudget/McpSessionTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpSessionTools.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"2fddf918c05c7ffde2238887e36e5ca3a24708ad","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpObjectStatsToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpObjectStatsTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/ObjectLockingBudgetLiveTests.cs","Darling/Darling.Tests/ObjectLockingWebDefaultTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Lite.Tests/McpToolsListBudget/McpObjectStatsTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/ObjectLockingBudgetTests.cs","Lite/Mcp/McpObjectStatsTools.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"76c59f4f7735dfc6a3efb91d120100cdf328171f","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Lite.Tests/McpToolsListBudget/McpBlockingTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpBlockingTools.cs"],"failed_classes":["Darling.Tests.McpToolGuideHeadsBlockingSlotsTests","Darling.Tests.McpToolGuideTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"7abb4ad6edba9db3061c7175a2288056885fb0dd","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthPayloadBudgetLiveTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js","Lite.Tests/CollectionHealthPayloadBudgetToolTests.cs","Lite/Mcp/McpHealthTools.cs"],"failed_classes":["Lite.Tests.McpToolsListBudgetTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-25","head_sha":"f5e001fe65c307fc4fb91ca714398a8a65fae236","event":"push","changed_files":["Darling/Darling.Tests/DarlingQueryHeatmapBudgetLiveTests.cs","Darling/Darling.Tests/DarlingQueryHeatmapTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryHeatmapTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/QueryHeatmapBudgetTests.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryHeatmap.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"659e11a39b4765eefa09fb8ea735dbbcd65eaa97","event":"push","changed_files":["Darling/Darling.Tests/AvailabilityGroupCountReadTests.cs","Darling/Darling.Tests/DarlingAgReaderTests.cs","Darling/Darling.Tests/DarlingAgStatesReaderLiveEqualityTests.cs","Darling/Darling.Tests/DarlingAgStatesReaderTests.cs","Darling/Darling.Tests/ViewerAvailabilityGroupsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingAgStatesReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AvailabilityGroups.cs","Lite.Tests/AgAvailabilityGroupNewestSnapshotEqualityTests.cs","Lite/Services/LocalDataService.AvailabilityGroups.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"78630b525eb490c4265d858e5cedc9df6ec113fd","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Lite.Tests/McpPageContractTests.cs","Lite.Tests/McpToolsListBudget/McpBlockingTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpBlockingTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.McpToolGuideHeadsBlockingSlotsTests","Darling.Tests.McpToolGuideTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"43cacc218313cac06b83beeda82f16d7d1ae30fe","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthPayloadBudgetLiveTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js","Lite.Tests/CollectionHealthPayloadBudgetToolTests.cs","Lite.Tests/McpToolsListBudget/McpHealthTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpHealthTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"f1a1894fd8559983d82a57e4d2f6ecdfe01b2250","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)","Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"171b60e5cb52e5c0e75f2d950217c26132c797ee","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpBlockingBudgetLiveTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpBlockingTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Lite.Tests/McpPageContractTests.cs","Lite.Tests/McpToolsListBudget/McpBlockingTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpBlockingTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"69ce7ff7beb259a633138f93968afe0e532a95a2","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingStoreHostProfileLiveTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"4b3fe51a977924fd19dd5b271db3fee82f80c93d","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileLiveTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.SerialLoopStoreSizeSourceTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"2c39e009ffe0ec21ddebfd547140388415ebd688","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs","Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"d7c5592f94ecdf9af6f7e0dab97830abac69e894","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/QueryStoreTopBudgetLiveTests.cs","Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs","Darling/Darling.Tests/QueryStoreTopWindowTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/QueryStoreTopBudgetTests.cs","Lite/Mcp/McpQueryTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"ed114b5b1724c71a674198254cacb39da0bf39d9","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpObjectStatsToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpObjectStatsTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/ObjectLockingBudgetLiveTests.cs","Darling/Darling.Tests/ObjectLockingWebDefaultTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Lite.Tests/McpToolsListBudget/McpObjectStatsTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/ObjectLockingBudgetTests.cs","Lite/Mcp/McpObjectStatsTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"3747867b5a50941776f5e38f55b881627b636dfd","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileLiveTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/TsqlConventionGuardTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.SerialLoopStoreSizeSourceTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"89c271dd17dbbec23a28a235602ef0fd1952f224","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs","Darling/Darling.Tests/DarlingMcpSessionToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpSessionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Lite.Tests/McpPageContractTests.cs","Lite.Tests/McpToolsListBudget/McpSessionTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpSessionTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"d0fb0ff183d7cfc588231b3e34ce48fbb64ff5e1","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpQueryStoreRegressionsBudgetLiveTests.cs","Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/QueryStoreRegressionsWebDefaultTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/QueryStoreRegressionsBudgetTests.cs","Lite/Mcp/McpQueryTools.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"354fc65faf06be290189179111773efcb9347a33","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionLogWebRowPreservesFullTextPinTests.cs","Darling/Darling.Tests/McpCollectionLogPerServerResponseBudgetLivePostgresTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/CollectionLogPerServerResponseBudgetToolTests.cs","Lite.Tests/McpToolsListBudget/McpHealthTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpHealthTools.cs","PerformanceMonitor.Common/Mcp/McpResponseBudget.cs"],"failed_classes":["Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"46d28fd5f39ccfba6e8678d1caa540e3daf0ef9b","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/DarlingMcpCustomViewCatalogSizeTests.cs","Darling/Darling.Tests/DarlingMcpCustomViewToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpCustomViewTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"3ad2d3870172cdb49b25eaaca3404f2fc187de4c","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileLiveTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/Darling.Tests/TsqlConventionGuardTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DarlingMissingCredentialMessageTests","Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"d534e587ae80fe765509cc400583a26eed991d9f","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/CompressionPhaseAssignmentTests.cs","Darling/Darling.Tests/DailySummaryNotCarriedTests.cs","Darling/Darling.Tests/DailySummaryReadShapeTests.cs","Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/MaterializationHoleScanShapeTests.cs","Darling/Darling.Tests/MeasurementContractCensusTests.cs","Darling/Darling.Tests/PayloadDimensionLiveTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs","Darling/Darling.Tests/RefreshCeilingStalenessTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/RollupBackfillTests.cs","Darling/Darling.Tests/RollupCoverageRoutingTests.cs","Darling/Darling.Tests/SuccessorDailyLiveTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Storage/RollupBackfill.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.FrozenRollupLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"37985a3c9dac392d51d767ba410f527b27c49bf1","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpQueryStoreRegressionsBudgetLiveTests.cs","Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreRegressionTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/QueryStoreRegressionsWebDefaultTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/QueryStoreRegressionsBudgetTests.cs","Lite/Mcp/McpQueryTools.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-25","head_sha":"772b0736064a167bc49ef03c876c92298d22b631","event":"pull_request","changed_files":["Lite.Tests/QueryWindowTruncationTests.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.McpPayloadContractCensusTests","Darling.Tests.McpToolGuideTests","Lite.Tests.McpToolGuideHeadsCollectionLogTests","Lite.Tests.McpToolGuideHeadsDataTests","Lite.Tests.McpToolGuideTests","Lite.Tests.McpToolsListBudgetTests"],"failed_jobs":["Darling PG tests (1)","Lite tests (2)","Darling PG tests (0)","build","Lite tests (3)","Lite tests (1)"]} +{"date":"2026-09-25","head_sha":"6e80eb9ca038b80ea6f5b74f6446e249a3374295","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisAsOfAnchorTests.cs","Darling/Darling.Tests/DarlingMcpAnalysisFindingsBudgetLiveTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/AnalysisAsOfAnchorTests.cs","Lite.Tests/McpAnalysisFindingsCommandTests.cs","Lite.Tests/McpToolsListBudget/McpAnalysisTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpAnalysisTools.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"e7e7d3b6b0457b99f6d19471c97198de76bf4408","event":"pull_request","changed_files":["Lite.Tests/QueryWindowTruncationTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.Slicers.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.McpPayloadContractCensusTests","Darling.Tests.McpToolGuideTests","Lite.Tests.McpToolGuideHeadsCollectionLogTests","Lite.Tests.McpToolGuideHeadsDataTests","Lite.Tests.McpToolGuideTests","Lite.Tests.McpToolsListBudgetTests"],"failed_jobs":["build","Lite tests (1)","Darling PG tests (1)","Darling PG tests (0)","Lite tests (3)","Lite tests (2)"]} +{"date":"2026-09-25","head_sha":"07496c9a85a55c83f4256856fd31a6a0c076a347","event":"push","changed_files":["Darling/Darling.Tests/AnalysisAsOfAnchorTests.cs","Darling/Darling.Tests/DarlingMcpAnalysisFindingsBudgetLiveTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Lite.Tests/AnalysisAsOfAnchorTests.cs","Lite.Tests/McpAnalysisFindingsCommandTests.cs","Lite.Tests/McpToolsListBudget/McpAnalysisTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite/Mcp/McpAnalysisTools.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"ed146a61d650888740e1ceb3f55070e6ec13fca9","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs"],"failed_classes":["Darling.Tests.DarlingMcpFleetSweepToolsTests","Darling.Tests.McpToolsListBudgetTests","Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["Darling PG tests (1)","build","Lite tests (1)"]} +{"date":"2026-09-25","head_sha":"35e0767f76f61dcc2598a68c27673ff270cd6e0b","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.DarlingMcpFleetSweepToolsTests","Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"68aee17c4c80fec90984285b0754960067533bc0","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs","Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.DarlingMcpFleetSweepToolsTests","Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"4382ed7cdef71bf907a181b7d3af9ace41f639a5","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCliCommandsHostCheckTests.cs","Darling/Darling.Tests/DarlingMcpStoreHostBudgetLiveTests.cs","Darling/Darling.Tests/DarlingMcpStoreHostToolsLiveTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpStoreHostTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/SerialLoopStoreSizeSourceTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreHostTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.DarlingMcpFleetSweepToolsTests","Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"67db04eb0e75f29e3d914255840fd7889130126c","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreLogins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingStoreConnection.cs","Darling/PerformanceMonitor.Darling.Storage/StoreConnectionSelfTest.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"08e6e818b4269fc2a193cc1636ad4982835b9b17","event":"pull_request","changed_files":["Darling/Darling.Tests/McpReadToolBudgetLiveTests.cs"],"failed_classes":["Darling.Tests.McpReadToolBudgetLiveTests","Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"d5444ff210c0f897bc9398dfe43832e863d10eb5","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreSessionTimeZonePinCensusTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreLogins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingStoreConnection.cs","Darling/PerformanceMonitor.Darling.Storage/StoreConnectionSelfTest.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"f0370dc8fc5ee4381abe62f0d0f1405c95a695e0","event":"push","changed_files":["Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/RawWindowFloorMcpLiveTests.cs","Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js","Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"f9ed975c705c4cc23592e40d39b731cb7c9fa75a","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreSessionTimeZoneLiveTests.cs","Darling/Darling.Tests/StoreSessionTimeZonePinCensusTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreLogins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingStoreConnection.cs","Darling/PerformanceMonitor.Darling.Storage/StoreConnectionSelfTest.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"4226552ea499174a53cca817059f40b27e7012a7","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolGuideHeads.CollectionLog.cs","Darling/Darling.Tests/McpToolGuideHeads.Data.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/McpToolGuideHeads.CollectionLog.cs","Lite.Tests/McpToolGuideHeads.Data.cs","Lite.Tests/QueryWindowTruncationTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.Slicers.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","Lite/Services/LocalDataService.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests","Lite.Tests.McpToolsListBudgetTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (2)"]} +{"date":"2026-09-25","head_sha":"762897cf9cffc5b9c209e3f8f9a288d768aba5e5","event":"pull_request","changed_files":["Darling/Darling.Tests/WebExceptionTextCensusTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFleetSweepEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingWebFailureLog.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"a3ece55524427f86c76766ea4e4530aea6601a76","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Perfmon.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Waits.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerNameListCache.cs","PerformanceMonitor.Common/PerformanceMonitor.Common.csproj"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.ViewerPerfmonRunningJobsLivePostgresTests","Darling.Tests.ViewerPerfmonSqlTests","Darling.Tests.ViewerWaitStatsLivePostgresTests","Darling.Tests.ViewerWaitStatsSqlTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)","Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"294c16c36e32aac01414fc931a9afaf7fd07bd22","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreBackfillTests.cs","Darling/PerformanceMonitor.Darling.Service/QueryStoreBackfill.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs","PerformanceMonitor.Collectors/QueryStoreBackfillState.cs"],"failed_classes":["Darling.Tests.CommandPlaneCommandTimeoutTests","Darling.Tests.PgTargetBlockingTests","Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"cff6610b2e0b5b8b7a99d5293d78f76687896d09","event":"push","changed_files":["Darling/Darling.Tests/DarlingStoreLoginsTests.cs","Darling/Darling.Tests/StoreConnectionStringAppendPinTests.cs","Darling/Darling.Tests/StoreSessionTimeZoneLiveTests.cs","Darling/Darling.Tests/StoreSessionTimeZonePinCensusTests.cs","Darling/Darling.Tests/ViewerCommandTimeoutTests.cs","Darling/Darling.Tests/ViewerDataServiceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreLogins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingStoreConnection.cs","Darling/PerformanceMonitor.Darling.Storage/StoreConnectionSelfTest.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.PgTargetBlockingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"92bcaa1946d0ea4e8891a2a85cf2d623445f4622","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/RetiredBaselineAggregateTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests"],"failed_jobs":["build"]} +{"date":"2026-09-25","head_sha":"227f5a83b127c35ea6c26b02eef42fd716b6277d","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerPerfmonRunningJobsTests.cs","Darling/Darling.Tests/ViewerTrendBucketingReviewTests.cs","Darling/Darling.Tests/ViewerWaitStatsTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Perfmon.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Waits.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerNameListCache.cs","PerformanceMonitor.Common/PerformanceMonitor.Common.csproj"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"4b899f6f43f700a2cc8a64c122c715ffcb7b340c","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthPayloadBudgetLiveTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpReadToolBudgetLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Lite.Tests/CollectionHealthPayloadBudgetToolTests.cs","Lite.Tests/McpReadToolBudgetTests.cs","Lite/Mcp/McpHealthTools.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"fc399bc63e08965c98b9453de4c00e628aedaa38","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs","Darling/Darling.Tests/PgTargetClockTests.cs","Darling/PerformanceMonitor.Darling.Analysis/BaselineCache.cs","Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetBaselineProvider.cs"],"failed_classes":["Darling.Tests.PgTargetBetweenWavesV3Tests","Darling.Tests.PgTargetClockLiveTests","Darling.Tests.PgTargetSampledWaitLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","build","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"961d93b4730ea08620d75b4e525a91e5f16b738e","event":"pull_request","changed_files":["Darling/Darling.Tests/FailureNoteRedactionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/CollectorRuntimeState.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingForcePlanTargetStateReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs"],"failed_classes":["Darling.Tests.PgTargetCollectionCaveatsLiveTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"a9814cad2304b4d401fdd74580571938119040fb","event":"pull_request","changed_files":["Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"29411369ab30b45ead7c0527757c6415bbd8afe1","event":"pull_request","changed_files":["Lite.Tests/LiteWaitPerfmonTrendBucketingTests.cs","Lite.Tests/PerfmonIntervalAggregationTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Services/LiteNameListCache.cs","Lite/Services/LocalDataService.Perfmon.cs","Lite/Services/LocalDataService.WaitStats.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.PerfmonCounterTypeRungTests"],"failed_jobs":["build"]} +{"date":"2026-09-25","head_sha":"a338b38170d1a72be711261be4a7c0722bfb214d","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPeerDisclosureTests.cs","Darling/Darling.Tests/FailureNoteRedactionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/CollectorRuntimeState.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingForcePlanTargetStateReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs"],"failed_classes":["Darling.Tests.PgTargetCollectionCaveatsLiveTests","Darling.Tests.StartupFailureTriageTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"8beb4d74a5c3ab6e2bfd108cd207a0581e48f8a7","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingHardenFilesVerbTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeRevertTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"6c4c79575959486778d38951503983e24bc4385b","event":"pull_request","changed_files":["Lite.Tests/PerformanceTrendDurationBucketingTests.cs","Lite/Services/LocalDataService.QueryStats.cs"],"failed_classes":["Darling.Tests.McpZeroIsAMeasurementTests"],"failed_jobs":["build"]} +{"date":"2026-09-25","head_sha":"b8fa109068d6ffeebd8fb41b294320837c443cff","event":"pull_request","changed_files":["Lite.Tests/DuckDbSentinelConnectionTests.cs","Lite.Tests/MuteRulesSurviveResetTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/MainWindow.xaml.cs"],"failed_classes":["PerformanceMonitorLite.Tests.AnalysisPassTokenThreadingTests"],"failed_jobs":["Lite tests (3)"]} +{"date":"2026-09-25","head_sha":"d118681de9f3abd734a48467dc1669299a3c0305","event":"pull_request","changed_files":["Lite.Tests/LiteMemoryFileIoTrendBucketingTests.cs","Lite.Tests/LiteWaitPerfmonTrendBucketingTests.cs","Lite.Tests/PerfmonIntervalAggregationTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Mcp/McpIoTools.cs","Lite/Services/LiteNameListCache.cs","Lite/Services/LocalDataService.FileIo.cs","Lite/Services/LocalDataService.Memory.cs","Lite/Services/LocalDataService.Perfmon.cs","Lite/Services/LocalDataService.WaitStats.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.PerfmonCounterTypeRungTests"],"failed_jobs":["build"]} +{"date":"2026-09-25","head_sha":"be7716e8e6f52adbbc3bc7b9b4e264b4a5261c4f","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionCaveatsTests.cs","Darling/Darling.Tests/CollectorRuntimeStateTests.cs","Darling/Darling.Tests/DarlingPeerDisclosureTests.cs","Darling/Darling.Tests/DarlingStoreLoginsTests.cs","Darling/Darling.Tests/FailureNoteRedactionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/CollectorRuntimeState.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingForcePlanTargetStateReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs","Lite.Tests/CollectionCaveatsTests.cs","Lite/Analysis/DuckDbFactCollector.cs","PerformanceMonitor.Analysis/AnalysisContext.cs"],"failed_classes":["Darling.Tests.PgTargetCollectionCaveatsLiveTests","Darling.Tests.StartupFailureTriageTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"298be53aa3252d3770f08bf962e5a20c2d65adeb","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingHardenFilesVerbTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeRevertTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeTests.cs","Darling/Darling.Tests/TsqlConventionGuardTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs"],"failed_classes":["Darling.Tests.ComposeStoreRolesLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"64e33e5b724dab960c8c71f7dae532c6c5913953","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs"],"failed_classes":["Darling.Tests.ActivityDrivenPlanFetchStoreTests","Darling.Tests.CheckpointsTimedRungTests","Darling.Tests.CollectionCaveatsRungTests","Darling.Tests.CollectionLogDrainForensicsStoreTests","Darling.Tests.CollectionLogFanoutRollupStoreTests","Darling.Tests.CollectionLogFetchPhaseSumsLivePostgresTests","Darling.Tests.CollectionLogFetchPhaseSumsStoreTests","Darling.Tests.CollectionLogPhaseSplitStoreTests","Darling.Tests.CollectorDatabaseScopeRungTests","Darling.Tests.CollectorStallProbeStoreTests","Darling.Tests.ComposeStatementTimeoutStoreTests","Darling.Tests.CustomAlertCoreMigrationTests","Darling.Tests.DarlingAnalysisStoreTests","Darling.Tests.DarlingCustomViewsTests","Darling.Tests.DarlingObservabilityTests","Darling.Tests.DarlingPlanCorrectionLiveMigrationTests","Darling.Tests.DarlingServerTagsTests","Darling.Tests.DeadlockRateBandRungTests","Darling.Tests.DeltaFamilyIntervalColumnsRungTests","Darling.Tests.DeltaFamilyIntervalCompletionRungTests","Darling.Tests.FileGrowthAlertStoreTests","Darling.Tests.FleetSweepCadenceKnobRungTests","Darling.Tests.FleetSweepStateRungTests","Darling.Tests.IncidentOccurrenceAccumulatorTests","Darling.Tests.IndexObjectStatsServerTimeIndexRungTests","Darling.Tests.LivePostgresStoreFixtureTests","Darling.Tests.LongRunningQueryExclusionKnobRungTests","Darling.Tests.MonitoredEngineKindStoreTests","Darling.Tests.MuteRuleReloadBeaconTests","Darling.Tests.NotificationRoutesRungTests","Darling.Tests.OversizedPlanBacklogPins","Darling.Tests.PerfmonCounterTypeRungTests","Darling.Tests.Pg18IoBytesTests","Darling.Tests.PgAlertCountKnobRungTests","Darling.Tests.PgCpuCapacityHeadroomTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryLivePostgresTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryRungTests","Darling.Tests.PgIndexBloatEstimateRungTests","Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.PgNumbackendsAndSampledMsLivePostgresTests","Darling.Tests.PgNumbackendsAndSampledMsRungTests","Darling.Tests.PgServerConfigScopeRungTests","Darling.Tests.PgServerConfigTests","Darling.Tests.PgStatementTextTests","Darling.Tests.PlanContentRetentionTests","Darling.Tests.PlanForceActionStoreTests","Darling.Tests.PostgresMajorVersionRegistryTests","Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.PostmasterStartTimeRungTests","Darling.Tests.PvsStatsStoreTests","Darling.Tests.QsCaptureModeRouteKnobToastLivePostgresTests","Darling.Tests.QsCaptureModeRouteKnobToastRungTests","Darling.Tests.QueryStoreHealthStoreTests","Darling.Tests.QueryStoreIntervalLatestRungTests","Darling.Tests.QueryStoreTextStoreTests","Darling.Tests.ScaffoldTests","Darling.Tests.SelfDiskWarnGbFloorRungTests","Darling.Tests.StartupHostProfileLogTests","Darling.Tests.StoreLogSelfMonitoringStoreTests","Darling.Tests.StoreSelfMetricsTests","Darling.Tests.TempDbCeilingStoreTests","Darling.Tests.TimeHonestyRungLivePostgresTests","Darling.Tests.TimeHonestyRungTests","PerformanceMonitor.Darling.Tests.StoreConnectionSelfTestLiveTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","build","Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"fd1fdb20d435d91f7da9aeb14ada2ac4fd81e22e","event":"pull_request","changed_files":["Lite.Tests/LiteMemoryFileIoTrendBucketingTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Mcp/McpIoTools.cs","Lite/Services/LocalDataService.FileIo.cs","Lite/Services/LocalDataService.Memory.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build"]} +{"date":"2026-09-25","head_sha":"856a7a174823f846a8da60e66aa88ad2352d5dfe","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionCaveatsTests.cs","Darling/Darling.Tests/CollectorRuntimeStateTests.cs","Darling/Darling.Tests/DarlingPeerDisclosureTests.cs","Darling/Darling.Tests/DarlingStoreLoginsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/FailureNoteRedactionTests.cs","Darling/Darling.Tests/WebExceptionTextCensusTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/CollectorRuntimeState.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingForcePlanTargetStateReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs","Lite.Tests/CollectionCaveatsTests.cs","Lite/Analysis/DuckDbFactCollector.cs","PerformanceMonitor.Analysis/AnalysisContext.cs"],"failed_classes":["Darling.Tests.PgTargetCollectionCaveatsLiveTests","Darling.Tests.SharedBaselineCacheTests","Darling.Tests.StartupFailureTriageTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"658bc329262ff59045315a0a8c52e4a6ff5712a3","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs"],"failed_classes":["Darling.Tests.ActivityDrivenPlanFetchStoreTests","Darling.Tests.CheckpointsTimedRungTests","Darling.Tests.CollectionCaveatsRungTests","Darling.Tests.CollectionLogDrainForensicsStoreTests","Darling.Tests.CollectionLogFanoutRollupStoreTests","Darling.Tests.CollectionLogFetchPhaseSumsLivePostgresTests","Darling.Tests.CollectionLogFetchPhaseSumsStoreTests","Darling.Tests.CollectionLogPhaseSplitStoreTests","Darling.Tests.CollectorDatabaseScopeRungTests","Darling.Tests.CollectorStallProbeStoreTests","Darling.Tests.ComposeStatementTimeoutStoreTests","Darling.Tests.CustomAlertCoreMigrationTests","Darling.Tests.DarlingAnalysisStoreTests","Darling.Tests.DarlingCustomViewsTests","Darling.Tests.DarlingObservabilityTests","Darling.Tests.DarlingPlanCorrectionLiveMigrationTests","Darling.Tests.DarlingServerTagsTests","Darling.Tests.DeadlockRateBandRungTests","Darling.Tests.DeltaFamilyIntervalColumnsRungTests","Darling.Tests.DeltaFamilyIntervalCompletionRungTests","Darling.Tests.FileGrowthAlertStoreTests","Darling.Tests.FleetSweepCadenceKnobRungTests","Darling.Tests.FleetSweepStateRungTests","Darling.Tests.IncidentOccurrenceAccumulatorTests","Darling.Tests.IndexObjectStatsServerTimeIndexRungTests","Darling.Tests.LivePostgresStoreFixtureTests","Darling.Tests.LongRunningQueryExclusionKnobRungTests","Darling.Tests.MonitoredEngineKindStoreTests","Darling.Tests.MuteRuleReloadBeaconTests","Darling.Tests.NotificationRoutesRungTests","Darling.Tests.OversizedPlanBacklogPins","Darling.Tests.PerfmonCounterTypeRungTests","Darling.Tests.Pg18IoBytesTests","Darling.Tests.PgAlertCountKnobRungTests","Darling.Tests.PgCpuCapacityHeadroomTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryLivePostgresTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryRungTests","Darling.Tests.PgIndexBloatEstimateRungTests","Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.PgNumbackendsAndSampledMsLivePostgresTests","Darling.Tests.PgNumbackendsAndSampledMsRungTests","Darling.Tests.PgServerConfigScopeRungTests","Darling.Tests.PgServerConfigTests","Darling.Tests.PgStatementTextTests","Darling.Tests.PlanContentRetentionTests","Darling.Tests.PlanForceActionStoreTests","Darling.Tests.PostgresMajorVersionRegistryTests","Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.PostmasterStartTimeRungTests","Darling.Tests.PvsStatsStoreTests","Darling.Tests.QsCaptureModeRouteKnobToastLivePostgresTests","Darling.Tests.QsCaptureModeRouteKnobToastRungTests","Darling.Tests.QueryStoreHealthStoreTests","Darling.Tests.QueryStoreIntervalLatestRungTests","Darling.Tests.QueryStoreTextStoreTests","Darling.Tests.ScaffoldTests","Darling.Tests.SelfDiskWarnGbFloorRungTests","Darling.Tests.StartupHostProfileLogTests","Darling.Tests.StoreLogSelfMonitoringStoreTests","Darling.Tests.StoreSelfMetricsTests","Darling.Tests.TempDbCeilingStoreTests","Darling.Tests.TimeHonestyRungLivePostgresTests","Darling.Tests.TimeHonestyRungTests","PerformanceMonitor.Darling.Tests.StoreConnectionSelfTestLiveTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)","Darling PG tests (1)","build"]} +{"date":"2026-09-25","head_sha":"02b7d9f692204d2cd3670007f36e1de42c7846a5","event":"pull_request","changed_files":["Darling/Darling.Tests/WebReadCancellationPinTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs"],"failed_classes":["Darling.Tests.EngineCapabilityReadWiringTests","Darling.Tests.McpServiceParameterDiSeatCensusTests","Darling.Tests.PgTargetMcpSurfaceTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-25","head_sha":"cb72c5d58312b81772a7332bbc5ab0adba833e7f","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreIntervalLatestRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGateTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideSlicerLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideWriterTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs"],"failed_classes":["Darling.Tests.McpReadCommandTimeoutTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"2cdce525a426660a1efbdd4e61af54f607cb8c9c","event":"pull_request","changed_files":["Darling/Darling.Tests/EngineCapabilityMissTests.cs","Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs","Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs","Darling/Darling.Tests/WebReadCancellationPinTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs"],"failed_classes":["PerformanceMonitor.Darling.Tests.RetiredBaselineAggregateLiveTests","Darling.Tests.PayloadDimensionLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"bcf18734bc9c7563bb394cc866b30d1325f4dcce","event":"push","changed_files":["Darling/Darling.Tests/CollectionCaveatsTests.cs","Darling/Darling.Tests/CollectorRuntimeStateTests.cs","Darling/Darling.Tests/DarlingPeerDisclosureTests.cs","Darling/Darling.Tests/DarlingStoreLoginsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/FailureNoteRedactionTests.cs","Darling/Darling.Tests/PgTargetCollectionCaveatsLiveTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/Darling.Tests/StartupFailureTriageTests.cs","Darling/Darling.Tests/WebExceptionTextCensusTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/CollectorRuntimeState.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingForcePlanTargetStateReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/PgPlanForceActionStore.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceBot.cs","Lite.Tests/CollectionCaveatsTests.cs","Lite/Analysis/DuckDbFactCollector.cs","PerformanceMonitor.Analysis/AnalysisContext.cs"],"failed_classes":["Darling.Tests.ComposeStoreRolesLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"3c16260c3c6e7618376774ebf362373001fc7726","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/EngineCapabilityMissTests.cs","Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs","Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs","Darling/Darling.Tests/QueryStoreClutterTests.cs","Darling/Darling.Tests/WebReadCancellationPinTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs"],"failed_classes":["Darling.Tests.RuntimePreconditionReadWiringTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-25","head_sha":"49e5906ce37e10a213df6700179d7c9722078eca","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreIntervalWideGateTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideMinSchemaVersionPinTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideSlicerLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideWriterTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/RawChunkIntervalRungHistoryRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs"],"failed_classes":["Darling.Tests.McpReadCommandTimeoutTests","Darling.Tests.QueryStoreIntervalWideWriterTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-25","head_sha":"40284b006187de062fe300a3d12a2065c029cdbf","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerCpuTempDbTests.cs","Darling/Darling.Tests/ViewerFileIoBlockingTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs"],"failed_classes":["Darling.Tests.PayloadDimensionLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-25","head_sha":"54fc10e90c4ab93a32dea96bfae0df84f0b377bd","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeStoreRolesLiveTests.cs","Darling/Darling.Tests/StartupCommandTimeoutTests.cs","Darling/Darling.Tests/StartupFailureTriageTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Service/StartupFailureTriage.cs"],"failed_classes":["Darling.Tests.StoreSessionTimeZonePinCensusTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-26","head_sha":"0aa324bfab631d2e1f5336d757b24defb25ea2cf","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreIntervalWideGateTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideMinSchemaVersionPinTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideWriterTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/RawChunkIntervalRungHistoryRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs"],"failed_classes":["Darling.Tests.AvailabilityGroupsTabRefreshTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"09c646b64c3908fd00f2107f0331ddb7f7013bd9","event":"pull_request","changed_files":["Darling/Darling.Tests/MemoryGrantTempDbBucketingLiveTests.cs","Darling/Darling.Tests/ViewerCpuTempDbTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs","Lite.Tests/MemoryGrantTempDbTrendBucketingTests.cs","Lite/Services/LocalDataService.MemoryGrants.cs","Lite/Services/LocalDataService.TempDb.cs"],"failed_classes":["Darling.Tests.DarlingMcpTrendToolsSurfaceAndSqlTests","Darling.Tests.ViewerMemorySqlTests","PerformanceMonitorLite.Tests.MeasurementContractCensusTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (0)","Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"a5f8d7d856353418e7c0a61fccb3547c94442f58","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerBlockingTrendBucketingTests.cs","Darling/Darling.Tests/ViewerFileIoBlockingTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs","Lite.Tests/LiteBlockingTrendBucketingTests.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.WaitingTasks.cs"],"failed_classes":["Darling.Tests.ViewerBlockingTrendBucketingSqlTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-26","head_sha":"c2a3e18567ba32b90a95de4fcd0e34a0f8b8f9d8","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgServerStateTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgTrendTools.cs"],"failed_classes":["Darling.Tests.McpPageContractTests","Darling.Tests.PgServerConfigScopeRungTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","build"]} +{"date":"2026-09-26","head_sha":"73737511b4ed3bb9a01b0128f94451218de4bf90","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreIntervalWideFaultInjectionCollection.cs","Darling/Darling.Tests/QueryStoreIntervalWideGateTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideMinSchemaVersionPinTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideWriterTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/RawChunkIntervalRungHistoryRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs"],"failed_classes":["Darling.Tests.AbandonedRunEraInvariantReadTests","Darling.Tests.AlertStoredValueTests","Darling.Tests.AnalysisPageCapLiveTests","Darling.Tests.AnomalyNullWindowLiveTests","Darling.Tests.AsOfWindowAnchorLivePostgresTests","Darling.Tests.BaselineDiscontinuityLivePostgresTests","Darling.Tests.CollectionLogFetchPhaseSumsLivePostgresTests","Darling.Tests.CollectorCostMaterialityFloorTests","Darling.Tests.CollectorCostRegressionPerRunTests","Darling.Tests.CollectorTableCatalogShadowTests","Darling.Tests.CompareAnalysisPgBandingLiveTests","Darling.Tests.CustomAlertLastFiredLiveTests","Darling.Tests.CustomAlertStateStoreLiveTests","Darling.Tests.CustomAlertTagSubtreeLiveTests","Darling.Tests.DailySummaryReadShapeLiveTests","Darling.Tests.DarkPastRetentionReadsOfflineLivePostgresTests","Darling.Tests.DarlingAlertReadAdapterTests","Darling.Tests.DarlingAnalysisServiceEngineRoutingTests","Darling.Tests.DarlingAnomalyBaselineTests","Darling.Tests.DarlingCollectionLogReadTests","Darling.Tests.DarlingCollectorRunnerTests","Darling.Tests.DarlingCollectorToggleVerbLivePostgresTests","Darling.Tests.DarlingComposeLivePostgresTests","Darling.Tests.DarlingDeltaSeederTests","Darling.Tests.DarlingEndpointToggleCliTests","Darling.Tests.DarlingMcpAgToolsLivePostgresTests","Darling.Tests.DarlingMcpAnalysisFindingsBudgetLiveTests","Darling.Tests.DarlingMcpCustomViewToolsLivePostgresTests","Darling.Tests.DarlingMcpHealthToolsLivePostgresTests","Darling.Tests.DarlingMcpObjectStatsToolsLivePostgresTests","Darling.Tests.DarlingMcpQueryStoreRegressionsBudgetLiveTests","Darling.Tests.DarlingMcpStoreHostToolsLiveTests","Darling.Tests.DarlingModuleMapTests","Darling.Tests.DarlingObservabilityTests","Darling.Tests.DarlingPgIndexBloatNanLivePostgresTests","Darling.Tests.DarlingPgReadSqlParsesLiveTests","Darling.Tests.DarlingPgTopQueriesLiveTests","Darling.Tests.DarlingQueryHeatmapBudgetLiveTests","Darling.Tests.DarlingSecuritySplitLiveTests","Darling.Tests.DarlingSignificantWaitsReadTests","Darling.Tests.DarlingStoreHostProfileLiveTests","Darling.Tests.DarlingTrendEmptyTests","Darling.Tests.DarlingWatermarkSeedLiveTests","Darling.Tests.DatabaseSizeLatestPlanShapeLiveTests","Darling.Tests.DefaultTraceEventFrameLivePostgresTests","Darling.Tests.DrillDownDopProvenanceLiveTests","Darling.Tests.EngineAwareVersionLabelTests","Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.FleetCardPostgresCpuLivePostgresTests","Darling.Tests.FleetCollectionHealthMemoLivePostgresTests","Darling.Tests.FleetReadsAreBoundedLivePostgresTests","Darling.Tests.HoldEarnedByDeliveryTests","Darling.Tests.JobHistoryGroupedStatsMatchWindowFunctionLiveTests","Darling.Tests.McpCollectionLogPerServerResponseBudgetLivePostgresTests","Darling.Tests.McpFleetResponseBudgetLivePostgresTests","Darling.Tests.McpReadToolBudgetLiveTests","Darling.Tests.NotificationRoutesLivePostgresTests","Darling.Tests.ObjectLockingBudgetLiveTests","Darling.Tests.OversizedPlanSweepRunRecordLivePostgresTests","Darling.Tests.ParameterSensitiveDrillDownTextLiveTests","Darling.Tests.PerServerCollectionHealthMemoLivePostgresTests","Darling.Tests.PgCopyWriterTests","Darling.Tests.PgFactCollectorTests","Darling.Tests.PgIoTrendDefaultBudgetLiveTests","Darling.Tests.PgLogEventMetricsLivePostgresTests","Darling.Tests.PgLogEventsCsvlogLiveTests","Darling.Tests.PgServerConfigToolBoundLiveTests","Darling.Tests.PgTargetAnomalyTests","Darling.Tests.PgTargetBufferDrillDownTests","Darling.Tests.PgTargetDeadlockDrillDownTests","Darling.Tests.PgTargetFactCollectorTests","Darling.Tests.PgTargetGrowthLiveTests","Darling.Tests.PgTargetKnobsTests","Darling.Tests.PgTargetPostureTests","Darling.Tests.PgTargetReplicationTests","Darling.Tests.PgTargetSeqScanTests","Darling.Tests.PlanCorrectionFrameLiveTests","Darling.Tests.PlanForceActionStoreTests","Darling.Tests.PlanRegressionDrillDownReuseLiveTests","Darling.Tests.PostgresEngineGateBehaviorTests","Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.PvsStatsStoreTests","Darling.Tests.QsCaptureModeRouteKnobToastLivePostgresTests","Darling.Tests.QueryStoreClutterLivePostgresTests","Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.QueryStoreReplicaSplitAnalysisLiveTests","Darling.Tests.QueryStoreTopMcpLiveTests","Darling.Tests.RawWindowFloorMcpLiveTests","Darling.Tests.RdsLogTimezoneReadLiveTests","Darling.Tests.RuntimePreconditionMissLivePostgresTests","Darling.Tests.ServerListAndSummaryPlanShapeTests","Darling.Tests.StoreConfigProviderTests","Darling.Tests.StoreLogGroupingLiveTests","Darling.Tests.StoreSelfMetricsTests","Darling.Tests.TimeHonestyRungLivePostgresTests","Darling.Tests.TimescaleSupportTests","Darling.Tests.TrendBucketingLiveTests","Darling.Tests.TrendFamilyBucketingLiveTests","Darling.Tests.ViewerCalendarRetentionLivePostgresTests","Darling.Tests.ViewerMemoryLivePostgresTests","Darling.Tests.ViewerOverviewLanesLivePostgresTests","Darling.Tests.ViewerPlanCacheLivePostgresTests","Darling.Tests.ViewerPlanHostLivePostgresTests","Darling.Tests.ViewerRecommendationsLivePostgresTests","PerformanceMonitor.Darling.Tests.HostObjectRollupLiveTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"c93141671fb83a19c203df516a66b5693ad7b3ab","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/MemoryGrantTempDbBucketingLiveTests.cs","Darling/Darling.Tests/ViewerCpuTempDbTests.cs","Darling/Darling.Tests/ViewerMemoryTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/TrendBucketSql.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs","Lite.Tests/MemoryGrantTempDbTrendBucketingTests.cs","Lite/Services/LocalDataService.MemoryGrants.cs","Lite/Services/LocalDataService.TempDb.cs"],"failed_classes":["Darling.Tests.ViewerMemoryLivePostgresTests","Darling.Tests.ViewerMemorySqlTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-26","head_sha":"6a334efcf1a0c00fc16cdc48bd56b6eccee9f4bc","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/MemoryGrantTempDbBucketingLiveTests.cs","Darling/Darling.Tests/ViewerCpuTempDbTests.cs","Darling/Darling.Tests/ViewerMemoryTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/TrendBucketSql.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs","Lite.Tests/MemoryGrantTempDbTrendBucketingTests.cs","Lite/Services/LocalDataService.MemoryGrants.cs","Lite/Services/LocalDataService.TempDb.cs"],"failed_classes":["Darling.Tests.ViewerMemoryLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"9a7d5c7d28571b04021684e78dad38e226b46670","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/MemoryGrantTempDbBucketingLiveTests.cs","Darling/Darling.Tests/ViewerCpuTempDbTests.cs","Darling/Darling.Tests/ViewerMemoryTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/TrendBucketSql.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.TempDb.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs","Lite.Tests/MemoryGrantTempDbTrendBucketingTests.cs","Lite/Services/LocalDataService.MemoryGrants.cs","Lite/Services/LocalDataService.TempDb.cs"],"failed_classes":["Darling.Tests.ViewerCpuSessionsPlanCacheTrendBucketingLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"24cc9c02ded441440f16e6e6837bddc5d25169ae","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/README.md","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.CollectionLogDrainForensicsStoreTests","Darling.Tests.CollectionLogFetchPhaseSumsStoreTests","Darling.Tests.CollectionLogPhaseSplitStoreTests","Darling.Tests.CollectorDatabaseScopeRungTests","Darling.Tests.DeadlockRateBandRungTests","Darling.Tests.DeltaFamilyIntervalColumnsRungTests","Darling.Tests.FleetSweepCadenceKnobRungTests","Darling.Tests.FleetSweepStateRungTests","Darling.Tests.FleetSweepStoreLivePostgresTests","Darling.Tests.LongRunningQueryExclusionKnobRungTests","Darling.Tests.NotificationRoutesRungTests","Darling.Tests.PerfmonCounterTypeRungTests","Darling.Tests.PgAlertCountKnobRungTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryRungTests","Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgNumbackendsAndSampledMsRungTests","Darling.Tests.PgServerConfigScopeRungTests","Darling.Tests.QsCaptureModeRouteKnobToastRungTests","Darling.Tests.QueryStoreIntervalWideRungTests","Darling.Tests.SelfDiskWarnGbFloorRungTests","Darling.Tests.StartupHostProfileLogTests","Darling.Tests.StoreLogViewerGateTests","Darling.Tests.TimeHonestyRungTests","Darling.Tests.ViewerSchemaVersionGateTests","Lite.Tests.CrossSkuSurfaceSourceTests"],"failed_jobs":["build","Darling PG tests (0)","Lite tests (3)","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"fd948645346b2820a390012754c01988dee1a460","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.ManagedConfVerdictsRungTests","Darling.Tests.StartupHostProfileLogTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (2)","Darling PG tests (1)","build"]} +{"date":"2026-09-26","head_sha":"2068d41265dfa37c53037460ec13ab7de9d159b4","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.ManagedConfFileTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"2e2c80f20af173a710ff73fa1e03f17d6522d21b","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreTopLiteralEndStraddleLiveTests.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"ce5436d155c4713fac2947c02f302ee5d7a78e10","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreDurationTrendTableLiveTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.QueryStoreDurationTrendTableLiveTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"76d44be270b06891519a2add7fd70f9e9873a666","event":"pull_request","changed_files":["Darling/Darling.Tests/PgSensitiveStatementFilterLiveTests.cs","Darling/Darling.Tests/PgSensitiveStatementFilterTests.cs","Darling/Darling.Tests/PgStatementTextScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgStatementTextScrub.cs","Darling/PerformanceMonitor.Darling.Storage/PgStatementText.cs","Darling/PerformanceMonitor.Darling.Storage/StoreStatementStats.cs","PerformanceMonitor.Collectors/PgBlockingCollector.cs","PerformanceMonitor.Collectors/PgSensitiveStatementFilter.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PgSensitiveStatementFilterTests","PerformanceMonitorLite.Tests.ScenarioTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)","Lite tests (1)"]} +{"date":"2026-09-26","head_sha":"c0adc53226670fdd89d7c28d782ee1b0be1e29b5","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanForceActionAuditRedactionTests.cs","Darling/Darling.Tests/PlanForceActionDetailScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceActionDetailScrub.cs"],"failed_classes":["PerformanceMonitorLite.Tests.ScenarioTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-26","head_sha":"68ddf1746657e3a8013ee0002eedf4eb1488ac27","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreTopLiteralEndStraddleLiveTests.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","PerformanceMonitorLite.Tests.ScenarioTests"],"failed_jobs":["Darling PG tests (2)","Lite tests (1)"]} +{"date":"2026-09-26","head_sha":"a75333e8e236710a1e864c4624de26cf8bb67988","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreDurationTrendTableLiveTests.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs"],"failed_classes":["Darling.Tests.QueryStoreDurationTrendTableLiveTests","Darling.Tests.QueryStoreIntervalWideGridLiveTests","Darling.Tests.QueryStoreTopMcpLiveTests","PerformanceMonitorLite.Tests.ScenarioTests"],"failed_jobs":["Lite tests (1)","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"8754210ebf37f73ca1dd120d8f6960477745d909","event":"pull_request","changed_files":["Darling/Darling.Tests/PgSensitiveStatementFilterLiveTests.cs","Darling/Darling.Tests/PgSensitiveStatementFilterTests.cs","Darling/Darling.Tests/PgStatementTextScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgStatementTextScrub.cs","Darling/PerformanceMonitor.Darling.Storage/PgStatementText.cs","Darling/PerformanceMonitor.Darling.Storage/StoreStatementStats.cs","PerformanceMonitor.Collectors/PgBlockingCollector.cs","PerformanceMonitor.Collectors/PgSensitiveStatementFilter.cs"],"failed_classes":["Darling.Tests.PgDeadlockNormalizationTests","PerformanceMonitorLite.Tests.ScenarioTests"],"failed_jobs":["Lite tests (1)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"801cb7c1d87049e38d7c7e6fbbc0cafbe72fe0fe","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanForceActionAuditRedactionTests.cs","Darling/Darling.Tests/PlanForceActionDetailScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceActionDetailScrub.cs"],"failed_classes":["Darling.Tests.PlanForceActionAuditRedactionTests","PerformanceMonitorLite.Tests.ScenarioTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (1)"]} +{"date":"2026-09-26","head_sha":"cbc985e2a5a4c942d7c0907210f211c8b23bde8c","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreDurationTrendTableLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs"],"failed_classes":["Darling.Tests.QueryStoreTopMcpLiveTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"8d29921a1fa552050083c7ab202f43e96e2bb65d","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.ManagedConfMigrationTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-26","head_sha":"8a4c6977c8386c0d2da0e58175ad3a3b7ca164ac","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs"],"failed_classes":["Darling.Tests.CollectionHealthPayloadBudgetLiveTests","Darling.Tests.OversizedPlanSweepRunRecordTests","Darling.Tests.PerServerCollectionHealthMemoTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"3efe402962c866944b24b5fa91d71584a660e4e7","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.ManagedConfMigrationTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-26","head_sha":"c3c584dced85e8a09737031271b14d41dee39d80","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/MuteRuleReloadRetentionTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAlertTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpMemoryGrantTools.cs","Darling/PerformanceMonitor.Darling.Service/PgMuteRuleStore.cs","Lite.Tests/MuteRuleServiceTests.cs","Lite.Tests/RemainingEmptyReadsToolTests.cs","Lite/Services/DuckDbMuteRuleStore.cs","PerformanceMonitor.Notifications/IMuteRuleStore.cs","deprecated/Dashboard/Services/JsonMuteRuleStore.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"87c7b1bbe5a117919efe954509cc9c5cb981d709","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanForceActionAuditRedactionTests.cs","Darling/Darling.Tests/PlanForceActionDetailCensus.cs","Darling/Darling.Tests/PlanForceActionDetailCensusTests.cs","Darling/Darling.Tests/PlanForceActionDetailScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceActionDetailScrub.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"c9a1a8145638896f27068c658ee343a4b17daaaa","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthPayloadBudgetLiveTests.cs","Darling/Darling.Tests/OversizedPlanSweepRunRecordTests.cs","Darling/Darling.Tests/PerServerCollectionHealthMemoTests.cs","Darling/Darling.Tests/WebReadCancellationPinTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerResolver.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"34a57f6f2ba27f30b6b37c55852557807c9d3f6b","event":"push","changed_files":["Darling/Darling.Tests/PgSensitiveStatementFilterLiveTests.cs","Darling/Darling.Tests/PgSensitiveStatementFilterTests.cs","Darling/Darling.Tests/PgStatementTextScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgStatementTextScrub.cs","Darling/PerformanceMonitor.Darling.Storage/PgStatementText.cs","Darling/PerformanceMonitor.Darling.Storage/StoreStatementStats.cs","PerformanceMonitor.Collectors/PgBlockingCollector.cs","PerformanceMonitor.Collectors/PgSensitiveStatementFilter.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"be67f6a2f3a8c0be1bd7d2ce049f6c9cfbe5e189","event":"pull_request","changed_files":["Darling/Darling.Tests/BaselineSupplyTests.cs","Darling/Darling.Tests/CompressionPhaseAssignmentTests.cs","Darling/Darling.Tests/DailySummaryNotCarriedTests.cs","Darling/Darling.Tests/DailySummaryReadShapeTests.cs","Darling/Darling.Tests/DailySummaryStitchedRangeTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/IntervalHonestHourlyRollupTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/MaterializationHoleScanShapeTests.cs","Darling/Darling.Tests/MeasurementContractCensusTests.cs","Darling/Darling.Tests/PayloadDimensionLiveTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RefreshCeilingProvenancePinTests.cs","Darling/Darling.Tests/RefreshCeilingStalenessTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/RollupBackfillTests.cs","Darling/Darling.Tests/RollupCoverageRoutingTests.cs","Darling/Darling.Tests/SuccessorDailyLiveTests.cs","Darling/Darling.Tests/TimescaleAggregateCompressionTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Storage/RollupBackfill.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"9174da32aa8730623280d202d522ebfb15fb0320","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.ManagedConfMigrationTests","Darling.Tests.ManagedConfRewriteTests","Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"f0dcb2fbc0e28f71fe93b5d881508129cb4e2f36","event":"pull_request","changed_files":["Darling/Darling.Tests/CapturingTestLogger.cs","Darling/Darling.Tests/PgSettingRedactorTests.cs","Darling/Darling.Tests/PgSettingScrubCandidateCensusTests.cs","Darling/Darling.Tests/PgSettingScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/PgSettingScrub.cs","PerformanceMonitor.Collectors/PgSettingRedactor.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"f93a136293c3981ff088c5fda99680fdb5e557f8","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreTopLiteralEndStraddleLiveTests.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"27a93fcb1adb5474926ab0dfbb7cc889c88660e1","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"239d269d68369d473f8e7bb96a3fe290a3858347","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreTopLiteralEndStraddleLiveTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"251e2adfeb10aa937f40b635bcb3668e635a280d","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationStepsTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationSteps.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.SharedBaselineCacheLiveTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"c618155e2e0e11fc0cb4edd5d2b369e01733b331","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanForceActionAuditRedactionTests.cs","Darling/Darling.Tests/PlanForceActionDetailCensus.cs","Darling/Darling.Tests/PlanForceActionDetailCensusTests.cs","Darling/Darling.Tests/PlanForceActionDetailScrubLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PlanForceActionDetailScrub.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"ded542a0563750d27f27c24bf53537a45c448f3e","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileSettingsTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs","Darling/Darling.Tests/ManagedConfMigrationStateTests.cs","Darling/Darling.Tests/ManagedConfMigrationStepsTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfMigrationWiringTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationState.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationSteps.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests","Darling.Tests.ManagedConfFileLiveTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"f7499cfb2762284862461b57f386f4fba596aaaa","event":"pull_request","changed_files":["Darling/Darling.Tests/PgSettingRedactorTests.cs","Darling/PerformanceMonitor.Darling.Service/PgSettingScrub.cs","PerformanceMonitor.Collectors/PgServerConfigCollector.cs","PerformanceMonitor.Collectors/PgSettingRedactor.cs"],"failed_classes":["Darling.Tests.PgSettingRedactorTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"2ccc5e07a9c272b7b783f699e445c15496329e0c","event":"pull_request","changed_files":["Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.FrozenRollupLiveTests","Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.RollupBackfillLiveTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"38adaf03d7f14d907b61a51c3b1822e85415aa47","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLatchSpinlockTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCacheSchedulerTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPvsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs"],"failed_classes":["Darling.Tests.McpLatestSnapshotStampTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-26","head_sha":"521ee52d394f25263bc82c82b64b535d7431e07a","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileSettingsTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs","Darling/Darling.Tests/ManagedConfMigrationStateTests.cs","Darling/Darling.Tests/ManagedConfMigrationStepsTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfMigrationWiringTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationState.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationSteps.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests","Darling.Tests.ManagedConfFileLiveTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"74797c4b9f8e0732a60b5d275c37ac475b5c102a","event":"pull_request","changed_files":["Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.FrozenRollupLiveTests","Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.RollupBackfillLiveTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"4bbb3138658b9585aba7366d986d9cdbe1137a4d","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertNotebookAuthoredTemplateTests.cs","Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-26","head_sha":"57c3c14fa4c7f61205b4454d6101ff17b583c876","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileSettingsTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs","Darling/Darling.Tests/ManagedConfMigrationStateTests.cs","Darling/Darling.Tests/ManagedConfMigrationStepsTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfMigrationWiringTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationState.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationSteps.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"241557c7d44f3f85c49de71928847e7a0fb2ead0","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileSettingsTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs","Darling/Darling.Tests/ManagedConfMigrationStateTests.cs","Darling/Darling.Tests/ManagedConfMigrationStepsTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfMigrationWiringTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationState.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationSteps.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"7d1627b90bf2808b1c5142ab9a281fd76667ca58","event":"pull_request","changed_files":["Darling/Darling.Tests/TopQueriesHourlyRoutingLiveTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"5f1034a36c1cc57b2d02b86bbfa94fbaff66ad1c","event":"pull_request","changed_files":["Darling/Darling.Tests/ServerWatermarkCacheRunnerLiveTests.cs","Darling/Darling.Tests/ServerWatermarkCacheTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/ServerWatermarkCache.cs","PerformanceMonitor.Collectors/CollectorDefinitionBase.cs","PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs","PerformanceMonitor.Collectors/ICollectorDefinition.cs","PerformanceMonitor.Collectors/JobHistoryCollector.cs","PerformanceMonitor.Collectors/MemoryPressureEventsCollector.cs","PerformanceMonitor.Collectors/SystemHealthEventsCollector.cs"],"failed_classes":["Darling.Tests.ServerWatermarkCacheRunnerLiveTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"7b00aa01ec2215860f3b28cb6351033f594e1f16","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileSettingsTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs","Darling/Darling.Tests/ManagedConfMigrationStateTests.cs","Darling/Darling.Tests/ManagedConfMigrationStepsTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfMigrationWiringTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfUpgradePathTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationState.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationSteps.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"3878626a2c107e15d77b2323fafc787cc22d2ebc","event":"pull_request","changed_files":[".gitattributes","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/DarlingStoreHostProfileTests.cs","Darling/Darling.Tests/Fixtures/ManagedConf/rehearsal-postgresql.conf","Darling/Darling.Tests/ManagedConfFileLiveTests.cs","Darling/Darling.Tests/ManagedConfFileSettingsTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationRunnerTests.cs","Darling/Darling.Tests/ManagedConfMigrationStateTests.cs","Darling/Darling.Tests/ManagedConfMigrationStepsTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfMigrationWiringTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/ManagedConfRewriteTests.cs","Darling/Darling.Tests/ManagedConfUpgradePathTests.cs","Darling/Darling.Tests/ManagedConfVerdictsLiveTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideRungTests.cs","Darling/Darling.Tests/StartupHostProfileLogTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreHostProfile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFileSettings.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationRunner.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationState.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigrationSteps.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/CrossSkuSurfaceSourceTests.cs","PerformanceMonitor.Alerting/AlertReadFailureCounter.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.StoreSessionTimeZonePinCensusTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"7540d1cb4b51fe5214a6b8ef6c1db0aa993e99cc","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCollectorCostTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetSweepTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpOversizedPlanBacklogTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStallProbeTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreLogTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreQueryStatsTools.cs"],"failed_classes":["Darling.Tests.OversizedPlanBacklogReadPins","Darling.Tests.StoreToastAndCheckpointerTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-26","head_sha":"0c3ed6a648f70bfdd8e9a580b712199e6a982148","event":"pull_request","changed_files":["Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.DarlingCloudIdentityProbeTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"dfb6b826c1769983340444e73c892fdbbeb51866","event":"pull_request","changed_files":["Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.DarlingSelfAlertTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-26","head_sha":"1cb56d0963ca3829cfcb5a6ceab01e3375bdbd3f","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionHoldAndCadenceRawRedirectLiveTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.DarlingTriageEndpointTests","Darling.Tests.RetentionHoldRatioKnobRungTests","Darling.Tests.TimescaleAvailabilityReprobeTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"af4c1a327469a4b3ae2dca3a6cc8e9d21336f8d8","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionHoldAndCadenceRawRedirectLiveTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.DarlingSelfAlertTests","Darling.Tests.DarlingTriageEndpointTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.RetentionHoldRatioKnobRungTests","Darling.Tests.TimescaleAvailabilityReprobeTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"d28291f02eb14bc8d40ae7c2c581e94bd920cd42","event":"pull_request","changed_files":["Darling/Darling.Tests/TopQueriesHourlyRoutingLiveTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs"],"failed_classes":["Darling.Tests.ManagedConfFileLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"79c8fb6ee52e5400026b953dc0ac6198426ccbe0","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionHoldAndCadenceRawRedirectLiveTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.DarlingTriageEndpointTests","Darling.Tests.RetentionHoldRatioKnobRungTests","Darling.Tests.TimescaleAvailabilityReprobeTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"52f9c129c25c7a0b1f99eac39e616bef22f10355","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgAutovacuumTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgDatabaseTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIndexUsageTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIoTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgKernelStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgPredicateTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgReplicationStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgSessionStatesTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgWraparoundTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgXminTools.cs"],"failed_classes":["Darling.Tests.PgLogEventMetricsParserTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-26","head_sha":"e51642f32a5ad0988c7642887b17933d93dccd58","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionHoldAndCadenceRawRedirectLiveTests.cs","Darling/Darling.Tests/RetentionHoldRatioKnobRungTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.NotificationRoutingTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"63ba0d2893cf51906e9d40d05cf8592f8a84733e","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgCpuUtilizationTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgIndexTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLogEventTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLoggingAuditTools.cs"],"failed_classes":["PerformanceMonitorLite.Tests.DuckDbSentinelConnectionTests"],"failed_jobs":["Lite tests (0)"]} +{"date":"2026-09-26","head_sha":"dbbaa4a52050d5cbdb9c9c0678523fd6dbfc42a8","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionHoldAndCadenceRawRedirectLiveTests.cs","Darling/Darling.Tests/RetentionHoldRatioKnobRungTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.QueryStoreIntervalWideGridLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"f526e44a7ad05ae3bd5c2df57952605a74664578","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs"],"failed_classes":["Darling.Tests.AnalysisPassTokenThreadingTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"76d20735c22f07203c5c30ab5669c1a09a5bb810","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisPassTokenThreadingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs"],"failed_classes":["Darling.Tests.AnalysisPassTokenThreadingTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"cac3ede73b6e4e207379f8f7b04bb5f4f180f8af","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeConvergeLiveTests.cs","Darling/Darling.Tests/RawPurgeTriggerGateErrorTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionHoldAndCadenceRawRedirectLiveTests.cs","Darling/Darling.Tests/RetentionHoldRatioKnobRungTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","PerformanceMonitorLite.Tests.AnalysisPassTokenThreadingTests"],"failed_jobs":["Darling PG tests (1)","Lite tests (3)","build"]} +{"date":"2026-09-26","head_sha":"ea3d63c3e6a41686595c95a7e58bfb1318b3f359","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/FrozenRollupLiveTests.cs","Darling/Darling.Tests/MaterializationHoleRepairTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/QueryStoreCorrectedRollupLiveTests.cs","Darling/Darling.Tests/RawArmedStateConvergeTests.cs","Darling/Darling.Tests/RawPurgeConvergeLiveTests.cs","Darling/Darling.Tests/RawPurgeTriggerGateErrorTests.cs","Darling/Darling.Tests/RawPurgeTriggerLiveTests.cs","Darling/Darling.Tests/RawRepairEpochStampTests.cs","Darling/Darling.Tests/RawRepairEpochTriggerLiveTests.cs","Darling/Darling.Tests/RawRetentionPurgeJobLiveTests.cs","Darling/Darling.Tests/RawRetentionServiceTriggeredLiveTests.cs","Darling/Darling.Tests/RetentionHoldAndCadenceRawRedirectLiveTests.cs","Darling/Darling.Tests/RetentionHoldRatioKnobRungTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/RollupBackfillLiveTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.DarlingSelfAlertTests","Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"c59e13654b1548c9e6206fedfeb974eb602c4438","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs"],"failed_classes":["Darling.Tests.ManagedConfFileLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"a6d62e93a3eb04e679da71abc11e6b0949e1087e","event":"pull_request","changed_files":["Darling/Darling.Tests/RawTablesLeaveCatalogSweepLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"],"failed_classes":["Darling.Tests.PayloadDimensionLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"1f9857239bd6da26bf10b03058a95a38154f9950","event":"pull_request","changed_files":["Darling/Darling.Tests/RawGatePerSlotSourceFloorLiveTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.RollupBackfillLiveTests","Darling.Tests.TimescaleSupportTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"01d4c7dba1714a5b5ecb12ef3da6412c87f8aead","event":"pull_request","changed_files":["Darling/Darling.Tests/PayloadDimensionLiveTests.cs","Darling/Darling.Tests/RawPurgeNowWiringTests.cs","Darling/Darling.Tests/RawTablesLeaveCatalogSweepLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"3493474200ee277b82be8a9cefd1e4cd32b54374","event":"pull_request","changed_files":["Darling/Darling.Tests/PayloadDimensionLiveTests.cs","Darling/Darling.Tests/RawPurgeNowWiringTests.cs","Darling/Darling.Tests/RawTablesLeaveCatalogSweepLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"dae420ebe034fbcd2b8291248972ee03b08dd73e","event":"pull_request","changed_files":["Darling/Darling.Tests/PgStatementStatsRowCoherentResetTests.cs","PerformanceMonitor.Collectors/CollectorDeltaCalculator.cs","PerformanceMonitor.Collectors/ICollectorDeltaCalculator.cs","PerformanceMonitor.Collectors/PgStatementStatsCollector.cs"],"failed_classes":["Lite.Tests.PgStatementStatsDeltaSkipTests","Lite.Tests.PgStatementStatsFlavorTests","Lite.Tests.ServerEpochTests"],"failed_jobs":["Lite tests (1)","Lite tests (0)"]} +{"date":"2026-09-26","head_sha":"3b2d41baaeecc4319df0c8e9344423504bd5a2fa","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingStoreUpgradeTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfUpgradePathTests.cs","Darling/Darling.Tests/ManagedPreloadAssert.cs"],"failed_classes":["Darling.Tests.QueryStoreIntervalWideGridLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"3a0df25e8e52fbe9c0c23a8199c9e100ff1ff355","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeStatementTimeoutReloadTests.cs","Darling/Darling.Tests/ComposeStatementTimeoutStoreTests.cs","Darling/Darling.Tests/ComposeStatementTimeoutV147MigrationLiveTests.cs","Darling/Darling.Tests/ComposeStoreRolesLiveTests.cs","Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DocCommentHygieneTests.cs","Darling/Darling.Tests/McpReadCommandTimeoutTests.cs","Darling/Darling.Tests/PairedClientDeadlinesTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSpec.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/McpCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.ComposeStoreRolesLiveTests","Darling.Tests.DarlingStoreLoginsTests","Darling.Tests.ManagedConfVerdictsRungTests","Darling.Tests.MigrationDataMovingRungCensusPins","Darling.Tests.StorageCommandTimeoutTests","Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-26","head_sha":"6d64a5690e0fb888332d01effb3e5cdf373bc51b","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeStatementTimeoutReloadTests.cs","Darling/Darling.Tests/ComposeStatementTimeoutStoreTests.cs","Darling/Darling.Tests/ComposeStatementTimeoutV147MigrationLiveTests.cs","Darling/Darling.Tests/ComposeStoreRolesLiveTests.cs","Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingStoreLoginsTests.cs","Darling/Darling.Tests/DocCommentHygieneTests.cs","Darling/Darling.Tests/ManagedConfVerdictsRungTests.cs","Darling/Darling.Tests/McpReadCommandTimeoutTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/PairedClientDeadlinesTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSpec.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/McpCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.ComposeStoreRolesLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-26","head_sha":"57e73f1ed5b05395c6f94a66145c68307384d6a0","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeStatementTimeoutV147MigrationLiveTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/ReadLatencyAccumulatorTests.cs","Darling/Darling.Tests/ReadLatencyFlushLiveTests.cs","Darling/Darling.Tests/ReadLatencyPercentilesTests.cs","Darling/Darling.Tests/ReadLatencyWebRecordingTests.cs","Darling/Darling.Tests/ReadOutcomeClassifierTests.cs","Darling/Darling.Tests/ScheduledPurgeLaunchTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/ReadLatencyAccumulator.cs","Darling/PerformanceMonitor.Darling.Service/ReadLatencyPercentiles.cs","Darling/PerformanceMonitor.Darling.Service/ReadOutcomeClassifier.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.SharedBaselineCacheTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-26","head_sha":"a9e36ec7251c2b904e463fe38cd1a13cab458592","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/ComposeStatementTimeoutV147MigrationLiveTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/ReadLatencyAccumulatorTests.cs","Darling/Darling.Tests/ReadLatencyFlushLiveTests.cs","Darling/Darling.Tests/ReadLatencyPercentilesTests.cs","Darling/Darling.Tests/ReadLatencyWebRecordingTests.cs","Darling/Darling.Tests/ReadOutcomeClassifierTests.cs","Darling/Darling.Tests/ScheduledPurgeLaunchTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/ReadLatencyAccumulator.cs","Darling/PerformanceMonitor.Darling.Service/ReadLatencyPercentiles.cs","Darling/PerformanceMonitor.Darling.Service/ReadOutcomeClassifier.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-26","head_sha":"a4218e75adf8a7ab18fcaa51e7e0e8246a831bf5","event":"pull_request","changed_files":["Darling/Darling.Tests/GetReadLatencyLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreLogins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpReadLatencyTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingReadLatencyReader.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs"],"failed_classes":["Darling.Tests.AsOfWindowAnchorTests","Darling.Tests.McpToolTypeRegistrationTests","Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)","Lite tests (1)"]} +{"date":"2026-09-26","head_sha":"abb3f9b719ea0e6ebdf36f167c2bfc735b457aec","event":"pull_request","changed_files":["Darling/Darling.Tests/AsOfWindowAnchorTests.cs","Darling/Darling.Tests/GetReadLatencyLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreLogins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpReadLatencyTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingReadLatencyReader.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.DarlingMcpInstructionsBudgetTests","Darling.Tests.McpToolsListBudgetTests","Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (1)"]} +{"date":"2026-09-27","head_sha":"6082b10647e46b2d13b2ef2e3d34db0e0bdb5ce5","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs","Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor.xml","Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor_high_sql_cpu.xml","Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor_low_memory.xml","Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor_normal.xml","Darling/Darling.Tests/Fixtures/SystemHealth/scheduler_monitor_other_process_cpu.xml","Darling/Darling.Tests/McpToolGuideHeads.HealthParser.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpHealthParserTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/SystemHealthParserTests.cs","Darling/Darling.Tests/ViewerSystemEventsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Lite.Tests/Fixtures/SystemHealth/scheduler_monitor.xml","Lite.Tests/Fixtures/SystemHealth/scheduler_monitor_high_sql_cpu.xml","Lite.Tests/Fixtures/SystemHealth/scheduler_monitor_low_memory.xml","Lite.Tests/Fixtures/SystemHealth/scheduler_monitor_normal.xml","Lite.Tests/Fixtures/SystemHealth/scheduler_monitor_other_process_cpu.xml","Lite.Tests/McpToolGuideHeads.HealthParser.cs","Lite.Tests/McpToolsListBudget/McpHealthParserTools.txt","Lite.Tests/SystemEventsReaderTests.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpHealthParserTools.cs","Lite/Services/LocalDataService.SystemEvents.cs","PerformanceMonitor.Common/SystemHealthModels.cs","PerformanceMonitor.Common/SystemHealthParser.cs","PerformanceMonitor.Common/SystemHealthSignificance.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests","Lite.Tests.McpToolsListBudgetTests"],"failed_jobs":["Darling PG tests (1)","Lite tests (2)","build"]} +{"date":"2026-09-27","head_sha":"27585711ab90f5c6791ba82c5b0da8842cd9a850","event":"pull_request","changed_files":[".github/workflows/build.yml","Darling/Darling.Tests/SignPathActionVersionTests.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)","Darling whole-tree guards"]} +{"date":"2026-09-27","head_sha":"f685b99b7b96260597277556e13b2a6771824139","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAgToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAgTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs"],"failed_classes":["Darling.Tests.McpPayloadContractCensusTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-27","head_sha":"dbd970fb8d61ae8b750ef2028c954dec41c24dce","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpAgToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAgTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs"],"failed_classes":["Darling.Tests.McpToolsListBudgetTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-27","head_sha":"da12c872e58696acff61f0741a8c54a5f0bcc384","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingWatermarkFloorPlanShapeLiveTests.cs","Darling/Darling.Tests/DarlingWatermarkFloorScanBoundLiveTests.cs","Darling/Darling.Tests/DarlingWatermarkSeedLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"],"failed_classes":["Darling.Tests.DarlingWatermarkFloorPlanShapeLiveTests","Darling.Tests.DarlingWatermarkFloorScanBoundLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-27","head_sha":"b3f2fb8f8d002093f57ac58f6f89832e2db58f6e","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingAgReaderTests.cs","Darling/Darling.Tests/FactAdvicePluralTests.cs","Darling/Darling.Tests/FactAdvicePluralWiringTests.cs","Darling/Darling.Tests/JobHistoryCapLabelTests.cs","Darling/Darling.Tests/ViewerAvailabilityGroupsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Common/AgTopology.cs","PerformanceMonitor.Common/JobHistoryCap.cs"],"failed_classes":["Darling.Tests.AgTopologyCardsTests","Lite.Tests.AgTopologyTests"],"failed_jobs":["build","Lite tests (0)","Darling PG tests (2)"]} +{"date":"2026-09-27","head_sha":"5a0dfb2b9b12c9f7cea0832b00d2bd020f072f4c","event":"pull_request","changed_files":["Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerStatus.cs","PerformanceMonitor.Common/SingleFlightTtlCache.cs"],"failed_classes":["Darling.Tests.DarlingAnomalyBaselineTests","Darling.Tests.OverviewFleetHealthSingleFlightLiveTests","Darling.Tests.SingleFlightTtlCacheTests","Darling.Tests.StoreSizeCacheLiveTests","Darling.Tests.WaitRateTileReadCountLiveTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-27","head_sha":"b36f267897e8c58f97b3a865aa6416ca9223ba6a","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreApplicationNameCensusTests.cs","Darling/Darling.Tests/StoreApplicationNamePinTests.cs","Darling/Darling.Tests/ViewerApplicationNameLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingStoreConnection.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerSettings.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-27","head_sha":"84a49674a9e3b2c46e708a219468f68e6c6f5e5a","event":"pull_request","changed_files":["Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs","Lite/Controls/JobHistoryTab.xaml","Lite/Controls/JobHistoryTab.xaml.cs"],"failed_classes":["Darling.Tests.ScopedLoadOrderingTests"],"failed_jobs":["Darling PG tests (2)","build"]} +{"date":"2026-09-27","head_sha":"323d11a4b7f3b5527536e665a9e784d132e15ae8","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingWatermarkFloorPlanShapeLiveTests.cs","Darling/Darling.Tests/DarlingWatermarkIndexEquivalenceLiveTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs"],"failed_classes":["Darling.Tests.ActivityDrivenPlanFetchStoreTests","Darling.Tests.CheckpointsTimedRungTests","Darling.Tests.CollectionCaveatsRungTests","Darling.Tests.CollectionLogDrainForensicsStoreTests","Darling.Tests.CollectionLogFanoutRollupStoreTests","Darling.Tests.CollectionLogFetchPhaseSumsLivePostgresTests","Darling.Tests.CollectionLogFetchPhaseSumsStoreTests","Darling.Tests.CollectionLogPhaseSplitStoreTests","Darling.Tests.CollectorDatabaseScopeRungTests","Darling.Tests.CollectorStallProbeStoreTests","Darling.Tests.ComposeStatementTimeoutStoreTests","Darling.Tests.ComposeStatementTimeoutV147MigrationLiveTests","Darling.Tests.CustomAlertCoreMigrationTests","Darling.Tests.DarlingAnalysisStoreTests","Darling.Tests.DarlingCustomViewsTests","Darling.Tests.DarlingObservabilityTests","Darling.Tests.DarlingPlanCorrectionLiveMigrationTests","Darling.Tests.DarlingServerTagsTests","Darling.Tests.DeadlockRateBandRungTests","Darling.Tests.DeltaFamilyIntervalColumnsRungTests","Darling.Tests.DeltaFamilyIntervalCompletionRungTests","Darling.Tests.FileGrowthAlertStoreTests","Darling.Tests.FleetSweepCadenceKnobRungTests","Darling.Tests.FleetSweepStateRungTests","Darling.Tests.IncidentOccurrenceAccumulatorTests","Darling.Tests.IndexObjectStatsServerTimeIndexRungTests","Darling.Tests.LivePostgresStoreFixtureTests","Darling.Tests.LongRunningQueryExclusionKnobRungTests","Darling.Tests.ManagedConfVerdictsRungTests","Darling.Tests.MonitoredEngineKindStoreTests","Darling.Tests.MuteRuleReloadBeaconTests","Darling.Tests.NotificationRoutesRungTests","Darling.Tests.OversizedPlanBacklogPins","Darling.Tests.PerfmonCounterTypeRungTests","Darling.Tests.Pg18IoBytesTests","Darling.Tests.PgAlertCountKnobRungTests","Darling.Tests.PgCpuCapacityHeadroomTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryLivePostgresTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryRungTests","Darling.Tests.PgIndexBloatEstimateRungTests","Darling.Tests.PgLogEventMetricsRungTests","Darling.Tests.PgLogEventsRungTests","Darling.Tests.PgNumbackendsAndSampledMsLivePostgresTests","Darling.Tests.PgNumbackendsAndSampledMsRungTests","Darling.Tests.PgServerConfigScopeRungTests","Darling.Tests.PgServerConfigTests","Darling.Tests.PgStatementTextTests","Darling.Tests.PlanContentRetentionTests","Darling.Tests.PlanForceActionStoreTests","Darling.Tests.PostgresMajorVersionRegistryTests","Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.PostmasterStartTimeRungTests","Darling.Tests.PvsStatsStoreTests","Darling.Tests.QsCaptureModeRouteKnobToastLivePostgresTests","Darling.Tests.QsCaptureModeRouteKnobToastRungTests","Darling.Tests.QueryStoreHealthStoreTests","Darling.Tests.QueryStoreIntervalLatestRungTests","Darling.Tests.QueryStoreIntervalWideRungTests","Darling.Tests.QueryStoreLivenessHotTouchLiveTests","Darling.Tests.QueryStoreTextStoreTests","Darling.Tests.RawChunkIntervalRungHistoryRungTests","Darling.Tests.ScaffoldTests","Darling.Tests.SelfDiskWarnGbFloorRungTests","Darling.Tests.StoreLogSelfMonitoringStoreTests","Darling.Tests.StoreSelfMetricsTests","Darling.Tests.TempDbCeilingStoreTests","Darling.Tests.TimeHonestyRungLivePostgresTests","Darling.Tests.TimeHonestyRungTests","PerformanceMonitor.Darling.Tests.StoreConnectionSelfTestLiveTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (2)","build","Darling PG tests (1)"]} +{"date":"2026-09-27","head_sha":"1b03b214f4a79e4add0cc36d57e37bd30e9a94ee","event":"pull_request","changed_files":["Darling/Darling.Tests/LitePerfmonWaitStatisticsArtifactSourceTests.cs","Lite.Tests/LitePerfmonWaitStatisticsArtifactLiveTests.cs","Lite/Controls/ServerTab.Pickers.cs","Lite/Mcp/McpPerfmonTools.cs","Lite/Services/LocalDataService.Perfmon.cs","Lite/Services/LocalDataService.TrendBuckets.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests","PerformanceMonitorLite.Tests.LitePerfmonWaitStatisticsArtifactLiveTests"],"failed_jobs":["build","Lite tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-27","head_sha":"b33399f21934a1f58ac26398f21c0c37361d9b1a","event":"pull_request","changed_files":["Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs"],"failed_classes":["Darling.Tests.DarlingCloudIdentityProbeTests","Darling.Tests.JobHistoryPerServerTopNLiveTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-27","head_sha":"bd31a3a390165ad4e93113de453888108d538026","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthAggregateTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/CollectionHealthRollupSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs"],"failed_classes":["Darling.Tests.FleetCollectionHealthMemoTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-27","head_sha":"8394dd35a0c3d860089ff18a2fff5d3cb35554f1","event":"pull_request","changed_files":["Darling/Darling.Tests/AgGroupIdLiveReaderTests.cs","Darling/Darling.Tests/AgGroupIdRungTests.cs","Darling/Darling.Tests/CollectionLogWatermarkAndJobHistoryIndexesRungTests.cs","Darling/Darling.Tests/DarlingAgReaderTests.cs","Darling/Darling.Tests/PgSchemaGeneratorTests.cs","Darling/Darling.Tests/QueryStoreLivenessHotTouchLiveTests.cs","Darling/Darling.Tests/ViewerAvailabilityGroupsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingAgStatesReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AvailabilityGroups.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite.Tests/AgCollectorDefinitionTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/LocalDataService.AgTopology.cs","PerformanceMonitor.Collectors/AgDatabaseReplicaStatesCollector.cs","PerformanceMonitor.Collectors/AgReplicaStatesCollector.cs","PerformanceMonitor.Common/AgTopology.cs"],"failed_classes":["Darling.Tests.AgGroupIdRungTests","Darling.Tests.QsCaptureModeRouteKnobToastRungTests","Lite.Tests.AgCollectorDefinitionTests","PerformanceMonitorLite.Tests.DuckDbSchemaEquivalenceTests","PerformanceMonitorLite.Tests.QsCaptureModeRungTests"],"failed_jobs":["Lite tests (0)","Lite tests (3)","build","Darling PG tests (1)"]} +{"date":"2026-09-27","head_sha":"40f6059a474d9c31dbac8c0c970fe5e11b904a25","event":"pull_request","changed_files":["Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs"],"failed_classes":["Darling.Tests.JobHistoryPerServerTopNLiveTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","build"]} +{"date":"2026-09-27","head_sha":"48d385c5822ebee00e7b5403f49d526cfcf5b7cf","event":"pull_request","changed_files":[".github/workflows/build.yml"],"failed_classes":["Darling.Tests.DarlingStoreUpgradeTests","Darling.Tests.ManagedConfUpgradePathTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-27","head_sha":"b652ab3d6f5635b63b4d61a36f0a790a4d1b5339","event":"pull_request","changed_files":["Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs"],"failed_classes":["Darling.Tests.JobHistoryPerServerTopNLiveTests"],"failed_jobs":["Darling PG tests (1)","build","Darling PG tests (0)"]} +{"date":"2026-09-27","head_sha":"fe1aa556b94a2c7170a871fc51ef02a371a70550","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingCollectorRunnerTests.cs","Darling/Darling.Tests/JobHistoryIdentityEpochTests.cs","Darling/Darling.Tests/JobHistoryNaturalKeyDedupeTests.cs","Darling/Darling.Tests/JobHistoryWatermarkEpochLiveTests.cs","Darling/Darling.Tests/LiteJobHistorySourceTests.cs","Darling/Darling.Tests/ServerWatermarkCacheRunnerLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Lite.Tests/JobHistoryDedupeAndWatermarkLiteTests.cs","Lite/Services/RemoteCollectorService.DefinitionRunner.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Collectors/INaturalKeyDedupedCollector.cs","PerformanceMonitor.Collectors/JobHistoryCollector.cs"],"failed_classes":["Darling.Tests.JobHistoryWatermarkEpochLiveTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-27","head_sha":"d4ccb2ed0816778aa405ad4ccd4a0213fe2d6659","event":"push","changed_files":["Darling/Darling.Tests/EventWindowedReadsCarryTheFloorTests.cs","Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs"],"failed_classes":["Darling.Tests.ScramVerifierLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-27","head_sha":"d0075921241a45fbc768b808394b8987b85bd0bf","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.PgLogEventsPipelineTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)","build"]} +{"date":"2026-09-27","head_sha":"13bf1036fcb986f806756d3dfbb020f6fb3d3df4","event":"pull_request","changed_files":["PerformanceMonitor.Collectors/PgLogEntryAssembler.cs"],"failed_classes":["Darling.Tests.PgLogEventMetricsParserTests","Darling.Tests.PgLogEventsPipelineTests","Darling.Tests.RdsLogEventIngestorTimezoneTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)","Darling PG tests (2)","build","Darling whole-tree guards"]} +{"date":"2026-09-27","head_sha":"f43cd0097d3c6c1063fd68d06def15cdacb7a084","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","Darling.Tests.PgLogEventsPipelineTests","Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)","Lite tests (1)"]} +{"date":"2026-09-27","head_sha":"bab1c4114bac757411a4d5e21121d67c9e464261","event":"pull_request","changed_files":["Darling/Darling.Tests/AgGroupIdRungTests.cs","Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CaggGroupIndexDropRungTests.cs","Darling/Darling.Tests/CaggGroupIndexDropUpgradeLiveTests.cs","Darling/Darling.Tests/IntervalDedupMaterializationIndexesTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/PgSchemaGeneratorTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.AbandonedRunEraInvariantReadTests","Darling.Tests.AgGroupIdLiveReaderTests","Darling.Tests.AgGroupIdRungTests","Darling.Tests.AlertNotebookAuthoredContextTests","Darling.Tests.AlertNotebookCollectorFreshnessLiveTests","Darling.Tests.AlertStoredValueTests","Darling.Tests.AnalysisAsOfAnchorLivePostgresTests","Darling.Tests.AnalysisCoverageLivePostgresTests","Darling.Tests.AnalysisPageCapLiveTests","Darling.Tests.AnalyzeWindowEmptyLivePostgresTests","Darling.Tests.AnomalyDetectorErrorGuardLiveTests","Darling.Tests.AnomalyNullWindowLiveTests","Darling.Tests.AnomalyObjectStatsLatestSnapshotsLiveTests","Darling.Tests.AsOfWindowAnchorLivePostgresTests","Darling.Tests.AvailabilityGroupCountReadTests","Darling.Tests.BaselineDiscontinuityLivePostgresTests","Darling.Tests.CaggGroupIndexDropLiveTests","Darling.Tests.CaggGroupIndexDropUpgradeLiveTests","Darling.Tests.CaptureDownChunkOrderTests","Darling.Tests.CiClusterWorkerSizingLiveTests","Darling.Tests.CollectionCaveatStoreLiveTests","Darling.Tests.CollectionHealthAggregateTests","Darling.Tests.CollectionHealthPayloadBudgetLiveTests","Darling.Tests.CollectionLogFetchPhaseSumsLivePostgresTests","Darling.Tests.CollectionLogWatermarkAndJobHistoryIndexesRungTests","Darling.Tests.CollectorCostMaterialityFloorTests","Darling.Tests.CollectorCostRegressionDispersionTests","Darling.Tests.CollectorCostRegressionMaterialityTests","Darling.Tests.CollectorCostRegressionPerRunTests","Darling.Tests.CollectorDatabaseScopeLivePostgresTests","Darling.Tests.CollectorStateContractTests","Darling.Tests.CollectorTableCatalogShadowTests","Darling.Tests.CompareAnalysisPgBandingLiveTests","Darling.Tests.ComposeAdHocModuleLivePostgresTests","Darling.Tests.ComposeQueryStoreLivePostgresTests","Darling.Tests.ComposeStatementTimeoutV147MigrationLiveTests","Darling.Tests.ComposeStoreRolesLiveTests","Darling.Tests.ComposeTopSeriesLivePostgresTests","Darling.Tests.CompressionEnableGuardLiveTests","Darling.Tests.ConfigChangeFamiliesLivePostgresTests","Darling.Tests.ConfigChangeTraceAnchorLivePostgresTests","Darling.Tests.CustomAlertEvaluateNowLiveTests","Darling.Tests.CustomAlertLastFiredLiveTests","Darling.Tests.CustomAlertResolveGrantLiveTests","Darling.Tests.CustomAlertRuleCapLiveTests","Darling.Tests.CustomAlertSeverityEscalationLiveTests","Darling.Tests.CustomAlertStateStoreLiveTests","Darling.Tests.CustomAlertTagSubtreeLiveTests","Darling.Tests.CustomAlertTeardownLiveTests","Darling.Tests.DailyStitchLiveTests","Darling.Tests.DailySummaryAndComposeStitchedLiveTests","Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.DailySummaryReadShapeLiveTests","Darling.Tests.DarkPastRetentionReadsOfflineLivePostgresTests","Darling.Tests.DarlingAgStatesReaderLiveEqualityTests","Darling.Tests.DarlingAlertReadAdapterTests","Darling.Tests.DarlingAlertTuningKnobsTests","Darling.Tests.DarlingAlertingTests","Darling.Tests.DarlingAnalysisPipelineTests","Darling.Tests.DarlingAnalysisServiceEngineRoutingTests","Darling.Tests.DarlingAnalysisStoreTests","Darling.Tests.DarlingAnomalyBaselineTests","Darling.Tests.DarlingBlockingStatsReadTests","Darling.Tests.DarlingBlockingTrendEmptyTests","Darling.Tests.DarlingCappedReadLivePostgresTests","Darling.Tests.DarlingCliCommandsHostCheckTests","Darling.Tests.DarlingCollectionHealthLatestNoteTests","Darling.Tests.DarlingCollectionLogReadTests","Darling.Tests.DarlingCollectorRunnerTests","Darling.Tests.DarlingCollectorToggleVerbLivePostgresTests","Darling.Tests.DarlingCommandExecutorTests","Darling.Tests.DarlingComposeLivePostgresTests","Darling.Tests.DarlingCurrentWaitsTrendTests","Darling.Tests.DarlingCustomViewsLiveTests","Darling.Tests.DarlingDailySummaryRangeTests","Darling.Tests.DarlingDeliveryModeTests","Darling.Tests.DarlingDeltaSeederTests","Darling.Tests.DarlingEmptyEnumerationInventoryTests","Darling.Tests.DarlingEndpointToggleCliTests","Darling.Tests.DarlingFleetReaderLivePostgresTests","Darling.Tests.DarlingIndexLockingRenamedDatabaseLivePostgresTests","Darling.Tests.DarlingLiveStoreExtensionParityTests","Darling.Tests.DarlingLockWaitTrendTests","Darling.Tests.DarlingMcpActiveQueriesBudgetLiveTests","Darling.Tests.DarlingMcpAgToolsLivePostgresTests","Darling.Tests.DarlingMcpAlertToolsLivePostgresTests","Darling.Tests.DarlingMcpAnalysisFindingsBudgetLiveTests","Darling.Tests.DarlingMcpBlockingBudgetLiveTests","Darling.Tests.DarlingMcpBlockingToolsLivePostgresTests","Darling.Tests.DarlingMcpConfigHistoryToolsLivePostgresTests","Darling.Tests.DarlingMcpConfigToolsLivePostgresTests","Darling.Tests.DarlingMcpCustomAlertToolsLivePostgresTests","Darling.Tests.DarlingMcpCustomViewToolsLivePostgresTests","Darling.Tests.DarlingMcpDataToolsLivePostgresTests","Darling.Tests.DarlingMcpDeadlockDetailBudgetLiveTests","Darling.Tests.DarlingMcpDefaultTraceToolsLivePostgresTests","Darling.Tests.DarlingMcpHealthParserToolsLivePostgresTests","Darling.Tests.DarlingMcpHealthToolsLivePostgresTests","Darling.Tests.DarlingMcpJobToolsLivePostgresTests","Darling.Tests.DarlingMcpLatchSpinlockToolsLivePostgresTests","Darling.Tests.DarlingMcpMemoryGrantToolsLivePostgresTests","Darling.Tests.DarlingMcpObjectStatsToolsLivePostgresTests","Darling.Tests.DarlingMcpPgLoggingAuditLivePostgresTests","Darling.Tests.DarlingMcpPgPlanQueryIdLiveTests","Darling.Tests.DarlingMcpPlanCacheSchedulerToolsLivePostgresTests","Darling.Tests.DarlingMcpPlanToolsLivePostgresTests","Darling.Tests.DarlingMcpQueryStoreRegressionsBudgetLiveTests","Darling.Tests.DarlingMcpServerAdminToolsLivePostgresTests","Darling.Tests.DarlingMcpSessionToolsLivePostgresTests","Darling.Tests.DarlingMcpStoreHostBudgetLiveTests","Darling.Tests.DarlingMcpStoreHostToolsLiveTests","Darling.Tests.DarlingMcpToolsTests","Darling.Tests.DarlingMcpTrendToolsLivePostgresTests","Darling.Tests.DarlingMemoryTrendGrantJoinTests","Darling.Tests.DarlingModuleMapTests","Darling.Tests.DarlingObservabilityTests","Darling.Tests.DarlingPerformanceTrendsReadTests","Darling.Tests.DarlingPerformanceTrendsTierRoutingLiveTests","Darling.Tests.DarlingPgAutovacuumVerdictLiveTests","Darling.Tests.DarlingPgDatabaseStatsLiveTests","Darling.Tests.DarlingPgIndexBloatCoverageLivePostgresTests","Darling.Tests.DarlingPgIndexBloatNanLivePostgresTests","Darling.Tests.DarlingPgIndexBloatRotationReadLivePostgresTests","Darling.Tests.DarlingPgReadSqlParsesLiveTests","Darling.Tests.DarlingPgSlotVerdictLiveTests","Darling.Tests.DarlingPgTopQueriesLiveTests","Darling.Tests.DarlingPlanCorrectionLiveMigrationTests","Darling.Tests.DarlingQueryHeatmapBudgetLiveTests","Darling.Tests.DarlingQueryHeatmapLiveTests","Darling.Tests.DarlingQueryStoreRegressionsLiveTests","Darling.Tests.DarlingRemainingEmptyReadsTests","Darling.Tests.DarlingRetentionTests","Darling.Tests.DarlingSecuritySplitLiveTests","Darling.Tests.DarlingSelfAlertTests","Darling.Tests.DarlingSignificantWaitsReadTests","Darling.Tests.DarlingStoreHostProfileLiveTests","Darling.Tests.DarlingStoreMetricsJobLoggingLivePostgresTests","Darling.Tests.DarlingStoreUpgradeTests","Darling.Tests.DarlingTrendEmptyTests","Darling.Tests.DarlingWatermarkFloorPlanShapeLiveTests","Darling.Tests.DarlingWatermarkFloorScanBoundLiveTests","Darling.Tests.DarlingWatermarkIndexEquivalenceLiveTests","Darling.Tests.DarlingWatermarkSeedLiveTests","Darling.Tests.DatabaseFileGrowthReadTests","Darling.Tests.DatabaseSizeLatestPlanShapeLiveTests","Darling.Tests.DatabaseStateAlertMemoryLiveTests","Darling.Tests.DefaultTraceEventFrameLivePostgresTests","Darling.Tests.DeltaFamilyIntervalCompletionLivePostgresTests","Darling.Tests.DrillDownDopProvenanceLiveTests","Darling.Tests.EngineAwareVersionLabelTests","Darling.Tests.EngineCapabilityMissLivePostgresTests","Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests","Darling.Tests.ExcludedDatabasesStoreLiveTests","Darling.Tests.FinOpsObjectGrowthBoundsMergeLiveTests","Darling.Tests.FinOpsTopConsumersMergeLiveTests","Darling.Tests.FleetCardPostgresCpuLivePostgresTests","Darling.Tests.FleetCollectionHealthByServerLiveEqualityTests","Darling.Tests.FleetCollectionHealthMemoLivePostgresTests","Darling.Tests.FleetReadsAreBoundedLivePostgresTests","Darling.Tests.FleetSweepStoreLivePostgresTests","Darling.Tests.ForcePlanFailuresAccessPathTests","Darling.Tests.FreshStoreWatermarkLiveTests","Darling.Tests.FrozenRollupLiveTests","Darling.Tests.GetReadLatencyLiveTests","Darling.Tests.HoldEarnedByDeliveryTests","Darling.Tests.IndexUsageBudgetLiveTests","Darling.Tests.IndexUsageTruncationLivePostgresTests","Darling.Tests.IntervalDedupMaterializationIndexesTests","Darling.Tests.IntervalHonestHourlyRollupLiveTests","Darling.Tests.JobHistoryGroupedStatsMatchWindowFunctionLiveTests","Darling.Tests.JobHistoryPerServerTopNLiveTests","Darling.Tests.JobHistoryWatermarkEpochLiveTests","Darling.Tests.LatestCpuReadShapeLivePostgresTests","Darling.Tests.LatestValueLookbackLivePostgresTests","Darling.Tests.LiveCleanupBatchTests","Darling.Tests.LivePostgresStoreFixtureTests","Darling.Tests.LocalClockBucketKeyLiveTests","Darling.Tests.ManagedConfVerdictsLiveTests","Darling.Tests.MaterializationHoleRepairLiveTests","Darling.Tests.MaterializationHoleScanShapeLiveTests","Darling.Tests.McpCollectionLogPerServerResponseBudgetLivePostgresTests","Darling.Tests.McpComposedQueryDeadlineLivePostgresTests","Darling.Tests.McpFilterSemanticsLivePostgresTests","Darling.Tests.McpFleetResponseBudgetLivePostgresTests","Darling.Tests.McpLatestSnapshotStampLivePostgresTests","Darling.Tests.McpPageContractLivePostgresTests","Darling.Tests.McpReadToolBudgetLiveTests","Darling.Tests.McpZeroIsAMeasurementLivePostgresTests","Darling.Tests.MemoryGrantTempDbBucketingLiveTests","Darling.Tests.MigrationLockWaitContentionTests","Darling.Tests.MigrationUpgradeLadderLiveTests","Darling.Tests.MonitoredEngineKindStoreTests","Darling.Tests.MuteRuleReloadBeaconLivePostgresTests","Darling.Tests.NotificationRoutesLivePostgresTests","Darling.Tests.ObjectLockingBudgetLiveTests","Darling.Tests.OversizedPlanBacklogReadLivePostgresTests","Darling.Tests.OversizedPlanSweepRunRecordLivePostgresTests","Darling.Tests.OverviewFleetHealthSingleFlightLiveTests","Darling.Tests.ParameterSensitiveDrillDownTextLiveTests","Darling.Tests.ParameterSensitivityClockFrameLiveTests","Darling.Tests.PayloadDimensionLiveTests","Darling.Tests.PerServerCollectionHealthMemoLivePostgresTests","Darling.Tests.PerfmonCounterTypeLivePostgresTests","Darling.Tests.PerfmonWaitStatisticsArtifactMcpLiveTests","Darling.Tests.PerfmonWaitStatisticsArtifactViewerLiveTests","Darling.Tests.PgAutovacuumDisabledSpellingLiveTests","Darling.Tests.PgBaselineProviderKeyedLiveTests","Darling.Tests.PgBlockingChainsDedupedByRootLivePostgresTests","Darling.Tests.PgCopyWriterTests","Darling.Tests.PgDatabaseSizeStatsAndHostMemoryLivePostgresTests","Darling.Tests.PgDeadlockCandidatePatternLiveTests","Darling.Tests.PgDeadlockNormalizationTests","Darling.Tests.PgDeadlockRemaskTests","Darling.Tests.PgDeadlocksCsvlogLiveTests","Darling.Tests.PgFactCollectorTests","Darling.Tests.PgIoTrendDefaultBudgetLiveTests","Darling.Tests.PgLogEventMetricsLivePostgresTests","Darling.Tests.PgLogEventsCsvlogLiveTests","Darling.Tests.PgLogEventsJsonlogLiveTests","Darling.Tests.PgLogEventsLivePostgresTests","Darling.Tests.PgLongRunningQueryExclusionLiveTests","Darling.Tests.PgNumbackendsAndSampledMsLivePostgresTests","Darling.Tests.PgPlanCaptureGuardedCastLiveTests","Darling.Tests.PgRaiseShapedRecordsLiveTests","Darling.Tests.PgReadBinaryFileCapabilityLiveTests","Darling.Tests.PgServerConfigOverrideLivePostgresTests","Darling.Tests.PgServerConfigPendingRestartLiveTests","Darling.Tests.PgServerConfigToolBoundLiveTests","Darling.Tests.PgSettingRedactionLivePostgresTests","Darling.Tests.PgSettingScrubLiveTests","Darling.Tests.PgStatementKeyedSetLiveTests","Darling.Tests.PgStatementTextScrubLiveTests","Darling.Tests.PgStatementTextUpsertLivePostgresTests","Darling.Tests.PgTargetAnomalyTests","Darling.Tests.PgTargetAuditConfigTests","Darling.Tests.PgTargetBloatLiveTests","Darling.Tests.PgTargetBlockingTests","Darling.Tests.PgTargetBufferDrillDownTests","Darling.Tests.PgTargetClockLiveTests","Darling.Tests.PgTargetCollectionCaveatsLiveTests","Darling.Tests.PgTargetConfigSnapshotBoundLiveTests","Darling.Tests.PgTargetDeadlockDrillDownTests","Darling.Tests.PgTargetFactCollectorTests","Darling.Tests.PgTargetGrowthLiveTests","Darling.Tests.PgTargetIoTests","Darling.Tests.PgTargetKernelTests","Darling.Tests.PgTargetKnobsTests","Darling.Tests.PgTargetMemoryTests","Darling.Tests.PgTargetPlanTests","Darling.Tests.PgTargetPostureTests","Darling.Tests.PgTargetQueriesTests","Darling.Tests.PgTargetReplicationTests","Darling.Tests.PgTargetSampledWaitLiveTests","Darling.Tests.PgTargetSeqScanTests","Darling.Tests.PgTargetSessionsTests","Darling.Tests.PgTargetTempTests","Darling.Tests.PgTargetTileBehaviourIoReplicationBlockingTests","Darling.Tests.PgTargetTileBehaviourTests","Darling.Tests.PgTargetTileBehaviourWalKernelSampledTests","Darling.Tests.PgTargetVacuumLiveTests","Darling.Tests.PgTargetWaitLiveTests","Darling.Tests.PgTargetWriteTests","Darling.Tests.PgWaitSamplerLiveTests","Darling.Tests.PlanCorrectionBudgetLiveTests","Darling.Tests.PlanCorrectionFrameLiveTests","Darling.Tests.PlanForceActionDetailScrubLiveTests","Darling.Tests.PlanForceActionStoreTests","Darling.Tests.PlanRegressionDrillDownReuseLiveTests","Darling.Tests.PlanRegressionIntervalTableEquivalenceTests","Darling.Tests.PostgresDatabaseListScreenLivePostgresTests","Darling.Tests.PostgresEngineGateBehaviorTests","Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.PvsStatsStoreTests","Darling.Tests.QsCaptureModeRouteKnobToastLivePostgresTests","Darling.Tests.QueryHighDopStaleMaxDopLiveTests","Darling.Tests.QueryStatsUnknownCpuLiveTests","Darling.Tests.QueryStoreBackfillCandidateLiveTests","Darling.Tests.QueryStoreClutterLivePostgresTests","Darling.Tests.QueryStoreCorrectedRollupLiveTests","Darling.Tests.QueryStoreDurationTrendTableLiveTests","Darling.Tests.QueryStoreFetchProbeLivePostgresTests","Darling.Tests.QueryStoreIntervalLatestWriterTests","Darling.Tests.QueryStoreIntervalWideGridLiveTests","Darling.Tests.QueryStoreIntervalWideWriterTests","Darling.Tests.QueryStoreLivenessHotTouchLiveTests","Darling.Tests.QueryStoreLivenessTablePruneLiveTests","Darling.Tests.QueryStoreReplicaSplitAnalysisLiveTests","Darling.Tests.QueryStoreSliceRepairLiveTests","Darling.Tests.QueryStoreStatePruneLivePostgresTests","Darling.Tests.QueryStoreTopBudgetLiveTests","Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests","Darling.Tests.QueryStoreTopMcpLiveTests","Darling.Tests.QueryStoreTrendRoutingLiveTests","Darling.Tests.RawChunkIntervalReconcilerLiveTests","Darling.Tests.RawGatePerSlotSourceFloorLiveTests","Darling.Tests.RawPurgeConvergeLiveTests","Darling.Tests.RawPurgeTriggerGateErrorTests","Darling.Tests.RawPurgeTriggerLiveTests","Darling.Tests.RawRepairEpochTriggerLiveTests","Darling.Tests.RawRetentionPurgeJobLiveTests","Darling.Tests.RawRetentionServiceTriggeredLiveTests","Darling.Tests.RawTablesLeaveCatalogSweepLiveTests","Darling.Tests.RawWindowFloorMcpLiveTests","Darling.Tests.RdsLogTimezoneReadLiveTests","Darling.Tests.ReadLatencyFlushLiveTests","Darling.Tests.ReadLatencyWebRecordingTests","Darling.Tests.RecurrenceLabelStoreReadTests","Darling.Tests.RetentionChunkGeometryLiveTests","Darling.Tests.RetentionHoldAndCadenceRawRedirectLiveTests","Darling.Tests.RetentionReevaluationLiveTests","Darling.Tests.RollupBackfillLiveTests","Darling.Tests.RuntimePreconditionMissLivePostgresTests","Darling.Tests.ScramVerifierLiveTests","Darling.Tests.SelfAlertDeliveryStampStoreLivePostgresTests","Darling.Tests.ServerIdentityFromStoreTests","Darling.Tests.ServerInventoryFleetMetricsLiveTests","Darling.Tests.ServerListAndSummaryPlanShapeTests","Darling.Tests.ServerWatermarkCacheRunnerLiveTests","Darling.Tests.SharedBaselineCacheLiveTests","Darling.Tests.SqlServerStoreTileBehaviourBatchQueryMemoryTests","Darling.Tests.SqlServerStoreTileBehaviourTests","Darling.Tests.StoreConfigProviderTests","Darling.Tests.StoreCopyPhaseLivePostgresTests","Darling.Tests.StoreLogGroupingLiveTests","Darling.Tests.StoreLogRemaskLiveTests","Darling.Tests.StoreMetricsLatestSkipScanLiveTests","Darling.Tests.StoreMetricsSummaryFirstLivePostgresTests","Darling.Tests.StoreSelfMetricsTests","Darling.Tests.StoreSelfMetricsTickRecoveryLiveTests","Darling.Tests.StoreSessionTimeZoneLiveTests","Darling.Tests.StoreSizeCacheLiveTests","Darling.Tests.StoreToastAndCheckpointerLivePostgresTests","Darling.Tests.SuccessorDailyLiveTests","Darling.Tests.TempDbCeilingStoreTests","Darling.Tests.TimeHonestyRungLivePostgresTests","Darling.Tests.TimescaleAggregateCompressionTests","Darling.Tests.TimescaleSupportTests","Darling.Tests.TopCpuQueriesTextLiveTests","Darling.Tests.TopProceduresHourlyRoutingLiveTests","Darling.Tests.TopQueriesHourlyRoutingLiveTests","Darling.Tests.TrendBucketingLiveTests","Darling.Tests.TrendFamilyBucketingLiveTests","Darling.Tests.TrendPayloadBudgetLiveTests","Darling.Tests.TuningDropLockRecoveryLiveTests","Darling.Tests.TuningReaderBudgetLiveTests","Darling.Tests.UncorroboratedRouteStoreKnobTests","Darling.Tests.ViewerApplicationNameLiveTests","Darling.Tests.ViewerBlockingDepthLivePostgresTests","Darling.Tests.ViewerBlockingStatsLivePostgresTests","Darling.Tests.ViewerBlockingTrendBucketingLivePostgresTests","Darling.Tests.ViewerCalendarRetentionLivePostgresTests","Darling.Tests.ViewerCollectionCaveatsLivePostgresTests","Darling.Tests.ViewerConfigChangesLivePostgresTests","Darling.Tests.ViewerConfigurationLivePostgresTests","Darling.Tests.ViewerCpuSchedulerLivePostgresTests","Darling.Tests.ViewerCpuSessionsPlanCacheTrendBucketingLiveTests","Darling.Tests.ViewerCpuTempDbLivePostgresTests","Darling.Tests.ViewerDailyHealthLivePostgresTests","Darling.Tests.ViewerDeadlockSeverityLivePostgresTests","Darling.Tests.ViewerFileIoBlockingLivePostgresTests","Darling.Tests.ViewerFinOpsIntervalHonestLiveTests","Darling.Tests.ViewerFleetRollupLivePostgresTests","Darling.Tests.ViewerLatchSpinlockLivePostgresTests","Darling.Tests.ViewerMemoryLivePostgresTests","Darling.Tests.ViewerOverviewLanesLivePostgresTests","Darling.Tests.ViewerPerfmonRunningJobsLivePostgresTests","Darling.Tests.ViewerPlanCacheLivePostgresTests","Darling.Tests.ViewerPlanHostLivePostgresTests","Darling.Tests.ViewerQueriesLivePostgresTests","Darling.Tests.ViewerQueriesRestLivePostgresTests","Darling.Tests.ViewerQueryTrendBucketingLiveTests","Darling.Tests.ViewerRecommendationsLivePostgresTests","Darling.Tests.ViewerSchemaVersionGateTests","Darling.Tests.ViewerSessionStatsLivePostgresTests","Darling.Tests.ViewerTrendBucketingLiveTests","Darling.Tests.ViewerTrendRoutingLivePostgresTests","Darling.Tests.ViewerW2aLivePostgresTests","Darling.Tests.ViewerWaitStatsLivePostgresTests","Darling.Tests.ViewerWave3LivePostgresTests","Darling.Tests.WaitRateTileReadCountLiveTests","Darling.Tests.WaitStatisticsArtifactSqlEquivalenceLiveTests","Darling.Tests.WebExceptionTextCensusTests","Darling.Tests.XminHorizonPersistenceReadTests","PerformanceMonitor.Darling.Tests.CpuRankingLiveTests","PerformanceMonitor.Darling.Tests.DistinctTextsLiveTests","PerformanceMonitor.Darling.Tests.EmptySupersededBaselineLiveTests","PerformanceMonitor.Darling.Tests.HostObjectRollupLiveTests","PerformanceMonitor.Darling.Tests.RetiredBaselineAggregateLiveTests","PerformanceMonitor.Darling.Tests.StoreConnectionSelfTestLiveTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-09-27","head_sha":"962b3f3eef41eb2e727884e0091a859b40f63603","event":"pull_request","changed_files":[".github/workflows/build.yml"],"failed_classes":["Darling.Tests.PgLogEventMetricsParserTests","Darling.Tests.PgLogEventsPipelineTests","Darling.Tests.RdsLogEventIngestorTimezoneTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)","Darling PG tests (2)","Darling whole-tree guards"]} +{"date":"2026-09-27","head_sha":"c7f73a53296bed09213e5fef15312109b0a36a7e","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.PgLogEventsPipelineTests","Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["build","Darling PG tests (0)","Lite tests (1)"]} +{"date":"2026-09-27","head_sha":"a178a7e48a2486fb3cd9b894faa170acdc976199","event":"pull_request","changed_files":[".github/workflows/build.yml"],"failed_classes":["Darling.Tests.PgLogEventMetricsLivePostgresTests","Darling.Tests.PgLogEventMetricsParserTests","Darling.Tests.PgLogEventsLivePostgresTests","Darling.Tests.PgLogEventsPipelineTests","Darling.Tests.RdsLogEventIngestorTimezoneTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (2)","Darling PG tests (1)","Darling whole-tree guards"]} +{"date":"2026-09-27","head_sha":"119d02272e279d9590b4233fa896434bb8638340","event":"pull_request","changed_files":["Darling/Darling.Tests/AgGroupIdRungTests.cs","Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CaggGroupIndexDropRungTests.cs","Darling/Darling.Tests/CaggGroupIndexDropUpgradeLiveTests.cs","Darling/Darling.Tests/IntervalDedupMaterializationIndexesTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/PgSchemaGeneratorTests.cs","Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.FleetSweepStoreLivePostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-27","head_sha":"b537c8dc31cb716ccc27defbdde15126012161af","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.PgLogEventsPipelineTests","Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["Lite tests (1)","Darling PG tests (0)","build"]} +{"date":"2026-09-27","head_sha":"8a14e42d5a0539e5b4a3d09238f90e1d34edf82c","event":"pull_request","changed_files":["PerformanceMonitor.Collectors/PgLogEntryAssembler.cs"],"failed_classes":["Darling.Tests.PgDeadlocksCsvlogLiveTests","Darling.Tests.PgLogEventsLivePostgresTests","Darling.Tests.PgLogEventsPipelineTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (1)","Darling whole-tree guards"]} +{"date":"2026-09-28","head_sha":"cbc658652efdc33dc69e7ee223005f87159168fd","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-28","head_sha":"14a060ababcbf5828a3240e374ae1d9ab9db6c34","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-28","head_sha":"d8242253c2fd1e1ad08f8ae8f95215ebb1e33460","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/RdsLogEventIngestorTimezoneTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","Lite.Tests/PgLoggingCollectorGateTests.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.PgLogEventsPipelineTests","Darling.Tests.RdsLogEventIngestorTimezoneTests","Darling.Tests.StoreLogClassifierTests","Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["Darling PG tests (2)","Lite tests (1)","build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"f4a1453b88a1092d354edf9836dddcf9b2bc0a11","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/RdsLogEventIngestorTimezoneTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","Lite.Tests/PgLoggingCollectorGateTests.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-09-28","head_sha":"3f6fa5c0c2f0c242b8a87ee1f14fc375ffe3f13d","event":"pull_request","changed_files":["Darling/Darling.Tests/CompressAfterStaggerLiveTests.cs","Darling/Darling.Tests/CompressAfterStaggerTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"c1fcf792d4fd328921dd8fec03a9102a6067d6eb","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfMigrationTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgPlanLogParserQueryIdPrefixTests.cs","Darling/Darling.Tests/RdsLogEventIngestorTimezoneTests.cs","Darling/Darling.Tests/StoreLogClassifierTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfMigration.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/PerformanceMonitor.Darling.Storage/StoreLogClassifier.cs","Lite.Tests/PgLoggingCollectorGateTests.cs","PerformanceMonitor.Collectors/PgLogEntryAssembler.cs","PerformanceMonitor.Collectors/PgLogEventClassifier.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgPlanLogParser.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests","Lite.Tests.PgLoggingCollectorGateTests"],"failed_jobs":["Darling PG tests (2)","Lite tests (1)"]} +{"date":"2026-09-28","head_sha":"fac9f8c0cdf78c6e9d3f1eca0306d19de76039db","event":"pull_request","changed_files":["Darling/Darling.Tests/BenefitScorerWiringTests.cs","Darling/Darling.Tests/PlanViewerBenefitDisplayTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Lite/Analysis/DrillDownCollector.Plans.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs","PerformanceMonitor.PlanAnalysis/PlanAnalysisPipeline.cs","PerformanceMonitor.PlanAnalysis/PlanWarningDisplay.cs","PerformanceMonitor.Ui/PlanViewerControl.Properties.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["Darling.Tests.PgWaitSamplerLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-28","head_sha":"89921c2ea8e2404347be4d2320332e344fee19f4","event":"pull_request","changed_files":["Darling/Darling.Tests/RollupFloorCacheLiveTests.cs","Darling/Darling.Tests/RollupFloorCacheTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.IntervalHonestHourlyRollupLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-28","head_sha":"8f33a24e5984da4b432858d9dfd68f4dacd8337c","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanSync4512PostParseTests.cs","Darling/Darling.Tests/PlanSync4512Tests.cs","Darling/Darling.Tests/PlanSync4514Tests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Lite/Analysis/DrillDownCollector.Plans.cs","PerformanceMonitor.PlanAnalysis/BenefitScorer.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs","PerformanceMonitor.PlanAnalysis/PlanLayoutEngine.cs","PerformanceMonitor.PlanAnalysis/PlanModels.cs","PerformanceMonitor.PlanAnalysis/PlanStatements.cs","PerformanceMonitor.PlanAnalysis/ShowPlanParser.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-28","head_sha":"98646b56d32a16cc6ea37574ad6a3de7b051a704","event":"pull_request","changed_files":["Darling/Darling.Tests/BenefitScorerWiringTests.cs","Darling/Darling.Tests/PgWaitSamplerLiveTests.cs","Darling/Darling.Tests/PlanViewerBenefitDisplayTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Lite/Analysis/DrillDownCollector.Plans.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs","PerformanceMonitor.PlanAnalysis/PlanAnalysisPipeline.cs","PerformanceMonitor.PlanAnalysis/PlanWarningDisplay.cs","PerformanceMonitor.Ui/PlanViewerControl.Properties.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-28","head_sha":"411d87afbe399342ded657d1651d44f30caab4a6","event":"pull_request","changed_files":["Darling/Darling.Tests/RollupFloorCacheLiveTests.cs","Darling/Darling.Tests/RollupFloorCacheTests.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-28","head_sha":"3e1ccb6ac7a365653f9da7e9766548729c299f28","event":"pull_request","changed_files":["Lite.Tests/FinOpsTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-28","head_sha":"481acb33ea1c7aa9fdcfd32868179275442eb1f5","event":"pull_request","changed_files":["Lite.Tests/FinOpsTests.cs","Lite.Tests/TestDataSeeder.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-28","head_sha":"7b24bc13ce1c0883018fc5b36f0eeacba98ea3c6","event":"pull_request","changed_files":["Darling/Darling.Tests/LiteAnalysisServiceCancellationSourceTests.cs","Lite.Tests/LiteAnalysisCancellationTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Mcp/McpAnalysisTools.cs"],"failed_classes":["PerformanceMonitorLite.Tests.LiteAnalysisCancellationTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-28","head_sha":"fc2901105b48a239f39a7d6f8a6a22a1c70df497","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingAgReaderTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAgTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests","Darling.Tests.DarlingMcpAgToolsLivePostgresTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"848ae6cda53ffa560c096dc7ed300845ebd3b416","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingAgReaderTests.cs","Darling/Darling.Tests/DarlingMcpAgToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpAgTools.txt","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAgReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpAgTools.cs"],"failed_classes":["Darling.Tests.DarlingMcpAgToolsLivePostgresTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"6fb64ded261eb330697d0cc64162b31101b3d748","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanSync4530Tests.cs","Darling/Darling.Tests/PlanSync4535ConfigPlumbingTests.cs","Darling/Darling.Tests/PlanSync4535NodeRulesATests.cs","Darling/Darling.Tests/PlanSync4535NodeRulesBTests.cs","Darling/Darling.Tests/PlanSync4535RuleNumberCensusTests.cs","Darling/Darling.Tests/PlanSync4535SeverityOverrideTests.cs","Darling/Darling.Tests/PlanSync4535StatementRulesTests.cs","Darling/Darling.Tests/PlanSync4566LegacyMarkerTests.cs","PerformanceMonitor.PlanAnalysis/AnalyzerConfig.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAnalysisPipeline.cs","PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs","PerformanceMonitor.PlanAnalysis/PlanModels.cs","PerformanceMonitor.PlanAnalysis/PlanStatements.cs","PerformanceMonitor.PlanAnalysis/PlanWarningDisplay.cs","PerformanceMonitor.PlanAnalysis/ServerMetadata.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-09-28","head_sha":"19583c0276360aa9ef2ec9b10f0952a14f78539f","event":"pull_request","changed_files":["Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs","Darling/Darling.Tests/PlanSync4530Tests.cs","Darling/Darling.Tests/PlanSync4535ConfigLoadingTests.cs","Darling/Darling.Tests/PlanSync4535ConfigPlumbingTests.cs","Darling/Darling.Tests/PlanSync4535NodeRulesATests.cs","Darling/Darling.Tests/PlanSync4535NodeRulesBTests.cs","Darling/Darling.Tests/PlanSync4535RuleNumberCensusTests.cs","Darling/Darling.Tests/PlanSync4535SeverityOverrideTests.cs","Darling/Darling.Tests/PlanSync4535StatementRulesTests.cs","Darling/Darling.Tests/PlanSync4566LegacyMarkerTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerSettings.cs","Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","Lite/App.xaml.cs","Lite/Controls/ServerTab.Plans.cs","Lite/Mcp/McpPlanTools.cs","Lite/config/settings.sample.json","PerformanceMonitor.PlanAnalysis/AnalyzerConfig.cs","PerformanceMonitor.PlanAnalysis/ConfigLoader.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs","PerformanceMonitor.PlanAnalysis/PlanAnalysisPipeline.cs","PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs","PerformanceMonitor.PlanAnalysis/PlanModels.cs","PerformanceMonitor.PlanAnalysis/PlanStatements.cs","PerformanceMonitor.PlanAnalysis/PlanWarningDisplay.cs","PerformanceMonitor.PlanAnalysis/ServerMetadata.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["Darling.Tests.FleetSweepWebFeedTests","Darling.Tests.SharedBaselineCacheTests","PerformanceMonitorLite.Tests.SettingsSampleTests"],"failed_jobs":["Lite tests (0)","Darling PG tests (2)","Darling PG tests (1)","build"]} +{"date":"2026-09-28","head_sha":"9aa0864d286037294a44853d85d547949f567b35","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanSync4530DrillDownLiveTests.cs","Darling/Darling.Tests/PlanSync4530EntryPointsTests.cs","Darling/Darling.Tests/PlanSync4530ReaderLiveTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj","Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs","Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs","Lite.Tests/PlanServerMetadataReaderTests.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Controls/FinOpsTab.xaml.cs","Lite/Controls/ServerTab.Plans.cs","Lite/Mcp/McpPlanTools.cs","Lite/Services/LocalDataService.PlanServerMetadata.cs","Lite/Windows/PlanViewerWindow.xaml.cs","Lite/Windows/ProcedureHistoryWindow.xaml.cs","Lite/Windows/QueryStatsHistoryWindow.xaml.cs","Lite/Windows/QueryStoreHistoryWindow.xaml.cs","Lite/Windows/WaitDrillDownWindow.xaml.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["PerformanceMonitorLite.Tests.PlanServerMetadataReaderTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)","Darling PG tests (2)","Lite tests (3)","Darling whole-tree guards"]} +{"date":"2026-09-28","head_sha":"0f3d97c46417ea9951444119b40c7154be79d1cf","event":"pull_request","changed_files":["Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs","Darling/Darling.Tests/PlanSync4535ConfigLoadingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerSettings.cs","Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","Lite/App.xaml.cs","Lite/Controls/ServerTab.Plans.cs","Lite/Mcp/McpPlanTools.cs","Lite/config/settings.sample.json","PerformanceMonitor.PlanAnalysis/ConfigLoader.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["Darling.Tests.FleetSweepWebFeedTests","Darling.Tests.SharedBaselineCacheTests","PerformanceMonitorLite.Tests.SettingsSampleTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (1)","Lite tests (0)"]} +{"date":"2026-09-28","head_sha":"a112c09d5dae43911e0947be7be16edf29fd1b32","event":"pull_request","changed_files":["Darling/Darling.Tests/PlanSync4530DrillDownLiveTests.cs","Darling/Darling.Tests/PlanSync4530EntryPointsTests.cs","Darling/Darling.Tests/PlanSync4530ReaderLiveTests.cs","Darling/Darling.Tests/packages.lock.json","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj","Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs","Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json","Lite.Tests/PlanServerMetadataReaderTests.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Controls/FinOpsTab.xaml.cs","Lite/Controls/ServerTab.Plans.cs","Lite/Mcp/McpPlanTools.cs","Lite/Services/LocalDataService.PlanServerMetadata.cs","Lite/Windows/PlanViewerWindow.xaml.cs","Lite/Windows/ProcedureHistoryWindow.xaml.cs","Lite/Windows/QueryStatsHistoryWindow.xaml.cs","Lite/Windows/QueryStoreHistoryWindow.xaml.cs","Lite/Windows/WaitDrillDownWindow.xaml.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["Darling.Tests.StorageCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"61da64f50fce7cb65b3c5cbdacab7b7a40a7b6c3","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeStatementTimeoutReloadTests.cs","Darling/Darling.Tests/ComposeTempFileLimitLiveTests.cs","Darling/Darling.Tests/ReadOutcomeClassifierTests.cs","Darling/Darling.Tests/WebExceptionTextCensusTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSpec.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingReadLatencyReader.cs","Darling/PerformanceMonitor.Darling.Service/ReadLatencyAccumulator.cs","Darling/PerformanceMonitor.Darling.Service/ReadOutcomeClassifier.cs","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.ControlPlaneReloadDurabilityTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"92a3e688986d43f28d04551bc77da2e7790137e2","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropRungTests.cs","Darling/Darling.Tests/IntervalFirstExecIndexRungTests.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.CaggGroupIndexDropLiveTests","Darling.Tests.CaggGroupIndexDropUpgradeLiveTests","Darling.Tests.MigrationDataMovingRungCensusPins"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"310326cc4e2c5d3a2c5e95efd02227de99c37fc7","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs"],"failed_classes":["Darling.Tests.StoreSelfMetricsTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"1d05c9cf78df0fd030219b8f4408882251c477b7","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs"],"failed_classes":["Darling.Tests.McpReadCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"5af839411c6eae48fbf98e0df87b18bc288f4e6d","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropRungTests.cs","Darling/Darling.Tests/IntervalFirstExecIndexRungTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.CaggGroupIndexDropLiveTests","Darling.Tests.CaggGroupIndexDropUpgradeLiveTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-28","head_sha":"cb5545cb1e70305431f89cd710bfc1e35b018aa9","event":"pull_request","changed_files":["CHANGELOG.md","Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CaggGroupIndexDropRungTests.cs","Darling/Darling.Tests/CaggGroupIndexDropUpgradeLiveTests.cs","Darling/Darling.Tests/CollectionHealthPayloadBudgetLiveTests.cs","Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/ComposeStatementTimeoutReloadTests.cs","Darling/Darling.Tests/ComposeTempFileLimitLiveTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/IntervalFirstExecIndexRungTests.cs","Darling/Darling.Tests/IntervalFirstExecIndexWideRungTests.cs","Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs","Darling/Darling.Tests/MigrationDataMovingRungCensusPins.cs","Darling/Darling.Tests/PlanSync4530ReaderLiveTests.cs","Darling/Darling.Tests/PlanSync4535ConfigLoadingTests.cs","Darling/Darling.Tests/PlanSync4597ServerContextFieldsLiveTests.cs","Darling/Darling.Tests/PlanSync4602AnalysisServiceConfigCensusTests.cs","Darling/Darling.Tests/PlanSync4613ReproScriptTests.cs","Darling/Darling.Tests/ProductiveZeroBandingTests.cs","Darling/Darling.Tests/QueryStoreTrendRoutingCachedLiveTests.cs","Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs","Darling/Darling.Tests/ReadOutcomeClassifierTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/Darling.Tests/SwallowedItemFailureTests.cs","Darling/Darling.Tests/Viewer4597Tests.cs","Darling/Darling.Tests/WebExceptionTextCensusTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSpec.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingReadLatencyReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/ReadLatencyAccumulator.cs","Darling/PerformanceMonitor.Darling.Service/ReadOutcomeClassifier.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/Themes/CoolBreezeTheme.xaml","Darling/PerformanceMonitor.Darling.Viewer/Themes/DarkTheme.xaml","Darling/PerformanceMonitor.Darling.Viewer/Themes/LightTheme.xaml","Darling/PerformanceMonitor.Darling.Viewer/ViewerActualPlanFlow.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Plans.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerSettings.cs","Darling/PerformanceMonitor.Darling.Viewer/WaitDrillDownWindow.xaml.cs","Darling/tools/provision-roles.sql","Lite.Tests/CollectionHealthPayloadBudgetToolTests.cs","Lite.Tests/PlanServerMetadataDatabaseArgTests.cs","Lite.Tests/SwallowedItemFailureTests.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","Lite/App.xaml.cs","Lite/Controls/ServerTab.Plans.cs","Lite/Mcp/McpHealthTools.cs","Lite/Mcp/McpPlanTools.cs","Lite/Services/LocalDataService.PlanServerMetadata.cs","Lite/Themes/CoolBreezeTheme.xaml","Lite/Themes/DarkTheme.xaml","Lite/Themes/LightTheme.xaml","Lite/config/settings.sample.json","PerformanceMonitor.Common/ServerHealthBands.cs","PerformanceMonitor.PlanAnalysis/ConfigLoader.cs","PerformanceMonitor.PlanAnalysis/McpPlanAnalysisFormatter.cs","PerformanceMonitor.PlanAnalysis/PlanAdvisoryAggregator.cs","PerformanceMonitor.PlanAnalysis/ReproScriptBuilder.cs","PerformanceMonitor.PlanAnalysis/ServerContextCard.cs","PerformanceMonitor.PlanAnalysis/ServerMetadata.cs","PerformanceMonitor.Ui/PlanViewerControl.Properties.cs","PerformanceMonitor.Ui/PlanViewerControl.Rendering.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs"],"failed_classes":["Darling.Tests.ComposeParameterCoverageTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"88204f7cd2346e391ca2d14dd59463f6577dc51d","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeSourceRouterTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs"],"failed_classes":["Darling.Tests.RdsDeadlockIngestorTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-28","head_sha":"171809d092c98217f79e6668ff4a0e0c926c5905","event":"pull_request","changed_files":["Darling/Darling.Tests/Viewer4570Tests.cs","Darling/Darling.Tests/Viewer4579Tests.cs","Darling/Darling.Tests/Viewer4627Tests.cs","Darling/Darling.Tests/Viewer4629Tests.cs","Darling/PerformanceMonitor.Darling.Viewer/Themes/CoolBreezeTheme.xaml","Darling/PerformanceMonitor.Darling.Viewer/Themes/DarkTheme.xaml","Darling/PerformanceMonitor.Darling.Viewer/Themes/LightTheme.xaml","Lite/Themes/CoolBreezeTheme.xaml","Lite/Themes/DarkTheme.xaml","Lite/Themes/LightTheme.xaml","PerformanceMonitor.PlanAnalysis/PlanDisplayText.cs","PerformanceMonitor.PlanAnalysis/PlanEdgeColour.cs","PerformanceMonitor.PlanAnalysis/PlanRowAccuracy.cs","PerformanceMonitor.Ui/PlanViewerControl.Minimap.cs","PerformanceMonitor.Ui/PlanViewerControl.Properties.cs","PerformanceMonitor.Ui/PlanViewerControl.Rendering.cs","PerformanceMonitor.Ui/PlanViewerControl.xaml.cs","deprecated/Dashboard/Themes/CoolBreezeTheme.xaml","deprecated/Dashboard/Themes/DarkTheme.xaml","deprecated/Dashboard/Themes/LightTheme.xaml"],"failed_classes":["Darling.Tests.PlanViewerWarningColorAndFixTests","Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Lite tests (1)","Darling PG tests (1)","build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"c243ded1b48a404d1a0168dccc6a404d9324db80","event":"pull_request","changed_files":["Darling/Darling.Tests/ScratchPostgres.cs"],"failed_classes":["Darling.Tests.StoreSessionTimeZoneLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-28","head_sha":"edbea21c52008aac952ef33a26bb78ebb710c765","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerStoreUnavailableShellTests.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/StoreUnavailableShell.cs"],"failed_classes":["Darling.Tests.ViewerFleetTimerFanOutPositionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-28","head_sha":"8b2191583216ec409b936a59f52f03a584e7962a","event":"pull_request","changed_files":["Darling/Darling.Tests/AnomalyObjectStatsLatestSnapshotsLiveTests.cs","Darling/Darling.Tests/CaptureDownChunkOrderTests.cs","Darling/Darling.Tests/DarlingAgStatesReaderLiveEqualityTests.cs","Darling/Darling.Tests/DarlingWatermarkFloorPlanShapeLiveTests.cs","Darling/Darling.Tests/EventWindowedReadsAreBoundedLivePostgresTests.cs","Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs","Darling/Darling.Tests/ForcePlanFailuresAccessPathTests.cs","Darling/Darling.Tests/JobHistoryPerServerTopNLiveTests.cs","Darling/Darling.Tests/LatestValueLookbackTests.cs","Darling/Darling.Tests/LivePlanShapeIsolationCensusTests.cs","Darling/Darling.Tests/PgServerConfigToolBoundTests.cs","Darling/Darling.Tests/PgTargetConfigSnapshotBoundTests.cs","Darling/Darling.Tests/ServerListAndSummaryPlanShapeTests.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"06815880fb3814dfc039eccfb20cc3adbc77cb39","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/DarlingWebAssetsTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js"],"failed_classes":["Darling.Tests.DarlingMcpCustomViewCatalogSizeTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-28","head_sha":"990cbc813ebdf9a13033c2896664953731fced0a","event":"pull_request","changed_files":["Darling/Darling.Tests/PgSettingRedactorTests.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-28","head_sha":"ec25f4ec0d4b0d5c105fe1a768474e7cfc0aaf40","event":"pull_request","changed_files":["Darling/Darling.Tests/ForcePlanFailuresAccessPathTests.cs","Darling/Darling.Tests/ForcePlanFailuresInvalidationPinsTests.cs","Darling/Darling.Tests/ForcePlanFailuresMemoLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/QueryStoreBackfill.cs"],"failed_classes":["PerformanceMonitorLite.Tests.BlockingDeadlockContextBuilderTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-09-28","head_sha":"bc389dd772ef7f97725ad238991d82eaef9242e3","event":"pull_request","changed_files":["Darling/Darling.Tests/DatabaseWatermarkCacheRunnerLiveTests.cs","Darling/Darling.Tests/DatabaseWatermarkCacheTests.cs","Darling/Darling.Tests/QueryStoreDatabaseWatermarkWiringPinTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DatabaseWatermarkCache.cs","Darling/PerformanceMonitor.Darling.Service/ServerWatermarkCache.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-28","head_sha":"281342891ec870936c96b854c088fc72cf288065","event":"pull_request","changed_files":["Darling/Darling.Tests/WebAutoRefreshPolicyTests.cs","Darling/Darling.Tests/WebFetchLayerTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/notebook.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/refresh-control.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/refresh-policy.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/views-api.js"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-28","head_sha":"76e1865c36d465a9df7a8253723cdb73ab46e38f","event":"pull_request","changed_files":["Darling/Darling.Tests/PgStatementEvictionNoteTests.cs","Darling/Darling.Tests/PgStatementsDeallocLiveTests.cs","Darling/Darling.Tests/PgStatementsEvictionFindingTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetStatementsEvictionRead.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgStatementTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Storage/DarlingPgStatementReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgStatementEvictionNote.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Postgres.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Postgres.cs","Lite.Tests/MeasurementContractCensusTests.cs","Lite.Tests/ServerEpochTests.cs","PerformanceMonitor.Analysis/EvictionFinding.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Config.cs","PerformanceMonitor.Analysis/PgTargetFactKeys.cs","PerformanceMonitor.Analysis/PgTargetScorer.Config.cs","PerformanceMonitor.Collectors/PgStatementStatsCollector.cs","PerformanceMonitor.Collectors/ServerEpoch.cs"],"failed_classes":["Darling.Tests.CollectorMeasurementSeamTests","Darling.Tests.PgServerConfigScopeRungTests","Darling.Tests.PgStatementStatsRowCoherentResetTests","Darling.Tests.PgTargetConfigSnapshotBoundTests","Lite.Tests.PgStatementStatsDeltaSkipTests","Lite.Tests.PgStatementStatsFlavorTests","Lite.Tests.ServerEpochTests","PerformanceMonitorLite.Tests.MeasurementContractCensusTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)","build","Lite tests (1)","Lite tests (0)"]} +{"date":"2026-09-28","head_sha":"f4bbfe5fd547e8f348800dd03ed7606d82bec744","event":"pull_request","changed_files":["Darling/Darling.Tests/DocCommentHygieneTests.cs","Darling/Darling.Tests/ViewerDisabledListBoxThemeTests.cs","Darling/PerformanceMonitor.Darling.Viewer/Themes/CoolBreezeTheme.xaml","Darling/PerformanceMonitor.Darling.Viewer/Themes/DarkTheme.xaml","Darling/PerformanceMonitor.Darling.Viewer/Themes/LightTheme.xaml"],"failed_classes":["Darling.Tests.PgLogEventsLivePostgresTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"0d66ce2272f78d37650e2d4d7d822c48d642ef3d","event":"pull_request","changed_files":[".claude/skills/lane-orders/SKILL.md","Darling/Darling.Tests/WaitRateTileReadCountLiveTests.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-09-29","head_sha":"8ab61d79df7d55a5db338babc1019c511189b067","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreWideStartLiveTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGateTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideHistoryNoteTests.cs","Darling/Darling.Tests/QueryStoreTopWindowTests.cs","Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs"],"failed_classes":["Darling.Tests.ComposeParameterCoverageTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"8921ce59dfb077199686bf90a6dc7aef35169bb0","event":"pull_request","changed_files":["Darling/Darling.Tests/PgLogEventsPipelineTests.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-09-29","head_sha":"b7afe92c2977bd3ab4a4d3dd62ba34821f740fa7","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/LiveTailQuery.cs","Darling/Darling.Tests/PgDeadlocksPlanCaptureResumeTests.cs","Darling/Darling.Tests/PgDeadlocksResumeLiveTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgLogTailStderrSiblingLiveTests.cs","Darling/Darling.Tests/PgPlanCaptureLiveTests.cs","Darling/Darling.Tests/PgRaiseShapedRecordsLiveTests.cs","Darling/Darling.Tests/PgServerLogTailBinaryRouteTests.cs","Darling/Darling.Tests/PgServerLogTailCsvJsonRotationLiveTests.cs","Darling/Darling.Tests/PgServerLogTailResumeTests.cs","Darling/Darling.Tests/PgServerLogTailRotationLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/PgPlanCaptureCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.CollectorStateContractTests","Darling.Tests.PgDeadlocksCsvlogLiveTests","Darling.Tests.PgLogEventsCsvlogLiveTests","Darling.Tests.PgLogEventsJsonlogLiveTests","Darling.Tests.PgLogEventsLivePostgresTests","Darling.Tests.PgPlanCaptureCsvLiveTests","Darling.Tests.TsqlConventionGuardTests","Lite.Tests.DefaultTraceEventsCollectorDefinitionTests","Lite.Tests.PgServerLogPathPinTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)","Lite tests (1)","Lite tests (0)","Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"b125deec3a61e42b6ff8ad6feb849f4d9090c633","event":"pull_request","changed_files":["Darling/Darling.Tests/Fixtures/OriginPlans/memory_grant_wait_plan.sqlplan","Darling/Darling.Tests/Fixtures/OriginPlans/serially-parallel.sqlplan","Darling/Darling.Tests/Fixtures/OriginPlans/spill_plan.sqlplan","Darling/Darling.Tests/PlanSync4531Tests.cs","Darling/Darling.Tests/PlanSync4535StatementRulesTests.cs","Darling/Darling.Tests/PlanSync4686ExcessiveMemoryGrantTests.cs","Darling/Darling.Tests/PlanSync4687CeGuessDetectionTests.cs","Darling/Darling.Tests/PlanSync4690ExpensiveOperatorExchangeTests.cs","PerformanceMonitor.PlanAnalysis/PlanAnalyzer.cs","PerformanceMonitor.PlanAnalysis/PlanModels.cs","PerformanceMonitor.PlanAnalysis/ShowPlanParser.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-29","head_sha":"a57bc17ec854c6604d575ec24f25f8b3e3ef658a","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/LiveTailQuery.cs","Darling/Darling.Tests/PgDeadlocksCsvlogLiveTests.cs","Darling/Darling.Tests/PgDeadlocksPlanCaptureResumeTests.cs","Darling/Darling.Tests/PgDeadlocksResumeLiveTests.cs","Darling/Darling.Tests/PgLogEventsCsvlogLiveTests.cs","Darling/Darling.Tests/PgLogEventsJsonlogLiveTests.cs","Darling/Darling.Tests/PgLogEventsPipelineTests.cs","Darling/Darling.Tests/PgLogTailStderrSiblingLiveTests.cs","Darling/Darling.Tests/PgPlanCaptureCsvLiveTests.cs","Darling/Darling.Tests/PgPlanCaptureLiveTests.cs","Darling/Darling.Tests/PgRaiseShapedRecordsLiveTests.cs","Darling/Darling.Tests/PgServerLogTailBinaryRouteTests.cs","Darling/Darling.Tests/PgServerLogTailCsvJsonRotationLiveTests.cs","Darling/Darling.Tests/PgServerLogTailResumeTests.cs","Darling/Darling.Tests/PgServerLogTailRotationLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/PgPlanCaptureCollectorDefinitionTests.cs","PerformanceMonitor.Collectors/PgDeadlocksCollector.cs","PerformanceMonitor.Collectors/PgLogEventsCollector.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgServerLogTail.cs"],"failed_classes":["Darling.Tests.CollectorStateContractTests","Darling.Tests.TsqlConventionGuardTests","Lite.Tests.DefaultTraceEventsCollectorDefinitionTests","Lite.Tests.PgServerLogPathPinTests"],"failed_jobs":["Darling PG tests (1)","Lite tests (1)","build","Lite tests (0)"]} +{"date":"2026-09-29","head_sha":"1954b70d2d5308678af313170d346d0f912a5d2a","event":"pull_request","changed_files":["Darling/Darling.Tests/PgTargetMeasuredLineageTests.cs","Darling/Darling.Tests/PgTargetMemoryTests.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Memory.cs","PerformanceMonitor.Analysis/PgTargetScorer.Memory.cs"],"failed_classes":["Darling.Tests.PgServerLogTailRotationLiveTests","Darling.Tests.PgTargetMemoryTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"0477bc1d12a2352fce89d48bf7b90e2d0497af7e","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingSecretsDecryptFailureTests.cs","Darling/Darling.Tests/ServerConnectPolicyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/ServerConnectPolicy.cs","Darling/PerformanceMonitor.Darling.Service/Targets/SqlServerTargetProvider.cs"],"failed_classes":["Darling.Tests.DarlingCloudIdentityProbeTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-29","head_sha":"03d9905807c3ed5cf6134cf047d671fea6b39bc3","event":"pull_request","changed_files":["Darling/Darling.Tests/RdsLogEventIngestorCsvlogTests.cs","Darling/Darling.Tests/RdsLogSourceTests.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogSource.cs"],"failed_classes":["Darling.Tests.PgServerLogTailRotationLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"a59f6dc910aaf76b792296625f40e8ed40cbc310","event":"pull_request","changed_files":["Darling/Darling.Tests/PgPlanCaptureCsvUnitTests.cs","Darling/Darling.Tests/PgPlanCaptureRaiseShapedUnitTests.cs","Darling/Darling.Tests/PgServerLogCsvParserTests.cs","PerformanceMonitor.Collectors/PgLogEntry.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgServerLogCsvParser.cs"],"failed_classes":["Darling.Tests.PgServerLogTailRotationLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"a60e32f36fe48521bcce777a497a04c63683d25f","event":"pull_request","changed_files":["Darling/Darling.Tests/HourlyWindowEdgesTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/TopProceduresHourlyRoutingLiveTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingLiveTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Storage/HourlyWindowEdges.cs"],"failed_classes":["Darling.Tests.McpToolGuideHeadsDataTests","Darling.Tests.McpToolGuideTests","Darling.Tests.McpToolsListBudgetTests","Darling.Tests.TopProceduresHourlyRoutingTests"],"failed_jobs":["Darling PG tests (1)","build","Lite tests (0)","Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"38f19e703047e1f2daa46e4d7c5f5afbb73734bd","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs"],"failed_classes":["Darling.Tests.DarlingStoreUpgradeRevertTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"216a4317f332258fb49dcceb5a818b9229c75795","event":"push","changed_files":["Darling/Darling.Tests/PgPlanCaptureCsvUnitTests.cs","Darling/Darling.Tests/PgPlanCaptureRaiseShapedUnitTests.cs","Darling/Darling.Tests/PgServerLogCsvParserTests.cs","PerformanceMonitor.Collectors/PgLogEntry.cs","PerformanceMonitor.Collectors/PgPlanCaptureCollector.cs","PerformanceMonitor.Collectors/PgServerLogCsvParser.cs"],"failed_classes":["Darling.Tests.PgServerLogTailRotationLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"7d74d8554134ecb006c5915de3a82dcc82b552f7","event":"pull_request","changed_files":["Darling/Darling.Tests/RdsLogEventIngestorCsvlogTests.cs","Darling/Darling.Tests/RdsLogSourceTests.cs","Darling/Darling.Tests/RdsResumePersistenceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCpuIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsIngestOutcome.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogEventIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogSource.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsPlanIngestor.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsResumeStore.cs"],"failed_classes":["Darling.Tests.PgServerLogTailRotationLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"0654199a72be8f5b18dcf19e330457a9d46dd273","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeRevertTests.cs","Darling/Darling.Tests/DarlingStoreUpgradeTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/DarlingStoreUpgrade.cs"],"failed_classes":["Darling.Tests.PgServerLogTailRotationLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"511f357e0dd46c92ec7d2ec4273275228f5f8e1c","event":"push","changed_files":["Darling/Darling.Tests/PgLogBurstAndRotationLiveTests.cs","Darling/Darling.Tests/PgServerLogTailRotationLiveTests.cs"],"failed_classes":["Darling.Tests.DarlingCloudIdentityProbeTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-29","head_sha":"3f390f5e59bd0233aa9a5b324a7119d84a891fb7","event":"pull_request","changed_files":["Darling/Darling.Tests/HourlyWindowEdgesTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/TopProceduresHourlyRoutingLiveTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingLiveTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Storage/HourlyWindowEdges.cs"],"failed_classes":["Darling.Tests.McpToolGuideHeadsDataTests","Darling.Tests.McpToolGuideTests","Darling.Tests.McpToolsListBudgetTests","Darling.Tests.TopProceduresHourlyRoutingTests"],"failed_jobs":["build","Darling PG tests (0)","Lite tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"3c8d96cdf2be3fe53e25695b789c66145b4a4211","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingDeployStaleFileTests.cs","Darling/tools/install-darling.ps1","Darling/tools/upgrade-darling.ps1"],"failed_classes":["Darling.Tests.DarlingFileSecurityTests","Darling.Tests.DarlingInstallLocationTests","Darling.Tests.DarlingRuntimePreflightTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"2c2da8b65a977d3f5b3f8a53c3ee5af552f59d22","event":"pull_request","changed_files":["Darling/Darling.Tests/PartialDailyHealLiveTests.cs","Darling/Darling.Tests/PartialDailyHealPureTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PartialDailyHeal.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.MaterializationHoles.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-09-29","head_sha":"ec5535c1a2aa296aebe09e4af111018919d6185b","event":"pull_request","changed_files":["Lite.Tests/CollectionCycleResumeTests.cs","Lite.Tests/DeltaFamilyIntervalColumnTests.cs","Lite.Tests/NewerSchemaFileGuardTests.cs","Lite.Tests/PerfmonCounterTypeTests.cs","Lite.Tests/QsCaptureModeRungTests.cs","Lite.Tests/SchemaColumnHealingTests.cs","Lite.Tests/TimeHonestyRungTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/SchemaVersionTooNewException.cs","Lite/Services/CollectionBackgroundService.cs"],"failed_classes":["Darling.Tests.QsCaptureModeRouteKnobToastRungTests","Darling.Tests.TimeHonestyRungTests"],"failed_jobs":["build"]} +{"date":"2026-09-29","head_sha":"d85dc2f70e0f1e8b7cff4479a33a639903ddc40e","event":"push","changed_files":["Darling/Darling.Tests/DarlingCliCommandsTests.cs","Darling/Darling.Tests/DarlingNetworkConfigEditorTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DarlingCustomViewsTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-29","head_sha":"2951d443b1405eec71e4b35272e936f813ac03cb","event":"push","changed_files":["Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DarlingMcpStoreHostToolsLiveTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"8ae9a3f65c439b01f48f0a2fcb597474dc73209a","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/IntervalFirstExecIndexWideRungTests.cs","Darling/Darling.Tests/QueryStoreIntervalEndRungTests.cs","Darling/Darling.Tests/QueryStorePlanFetchTests.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Lite.Tests/GoldenCollectorSchema.cs","Lite.Tests/QueryStoreCollectorDefinitionTests.cs","Lite.Tests/QueryStoreIntervalEndRungTests.cs","Lite.Tests/SchemaColumnHealingTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/SchemaVersionTooNewException.cs","PerformanceMonitor.Collectors/QueryStoreCollector.cs"],"failed_classes":["Darling.Tests.QueryStoreIntervalEndRungTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"85773bdac87fdf6efe84bf61122c5b1575133d7b","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/DarlingBlockingReaderServerClockTests.cs","Darling/Darling.Tests/DarlingDefaultTraceReaderServerClockTests.cs","Darling/Darling.Tests/DarlingJobReaderServerClockTests.cs","Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs","Darling/Darling.Tests/DarlingObjectStatsReaderServerClockTests.cs","Darling/Darling.Tests/DarlingPvsReaderServerClockTests.cs","Darling/Darling.Tests/DarlingServerClockReaderTests.cs","Darling/Darling.Tests/McpPayloadClockFrameDisciplineTests.cs","Darling/Darling.Tests/McpServerClockTestSupport.cs","Darling/Darling.Tests/PlanCorrectionFrameLiveTests.cs","Darling/Darling.Tests/ServerLocalReadFrameDisciplineTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingJobReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPvsReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingServerClockReader.cs"],"failed_classes":["Darling.Tests.ReadLatencyWebRecordingTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-29","head_sha":"0d6caa336094a50ac2d6afdc2527d7171c15aa13","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CheckpointLongestSyncRungTests.cs","Darling/Darling.Tests/CheckpointSyncSamplerTests.cs","Darling/Darling.Tests/DarlingCommandExecutorTests.cs","Darling/Darling.Tests/DarlingRetentionTests.cs","Darling/Darling.Tests/PurgeNowBackgroundTests.cs","Darling/Darling.Tests/QueryStoreIntervalEndRungTests.cs","Darling/Darling.Tests/RawPurgeNowWiringTests.cs","Darling/Darling.Tests/RawTablesLeaveCatalogSweepLiveTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/RetentionWalPacerLiveTests.cs","Darling/Darling.Tests/RetentionWalPacerTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/CheckpointSyncSampler.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Service/RetentionWalPacer.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ControlCommands.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs"],"failed_classes":["Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.QueryStoreIntervalEndRungTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"3247e2f88ea4017a2f25ddf13bebc8bec6954ed3","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CheckpointLongestSyncRungTests.cs","Darling/Darling.Tests/CheckpointSyncSamplerTests.cs","Darling/Darling.Tests/DarlingCommandExecutorTests.cs","Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs","Darling/Darling.Tests/DarlingRetentionTests.cs","Darling/Darling.Tests/PurgeNowBackgroundTests.cs","Darling/Darling.Tests/PurgeNowTotalsWatchTests.cs","Darling/Darling.Tests/QueryStoreIntervalEndRungTests.cs","Darling/Darling.Tests/RawPurgeNowWiringTests.cs","Darling/Darling.Tests/RawTablesLeaveCatalogSweepLiveTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/RetentionWalPacerLiveTests.cs","Darling/Darling.Tests/RetentionWalPacerTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/CheckpointSyncSampler.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Service/RetentionWalPacer.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Darling/PerformanceMonitor.Darling.Viewer/PurgeNowWatch.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ControlCommands.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs"],"failed_classes":["Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.QueryStoreIntervalEndRungTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"e8ddea0318de7b01545e665cb3b2a83a7b3117cd","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CheckpointLongestSyncRungTests.cs","Darling/Darling.Tests/CheckpointSyncSamplerTests.cs","Darling/Darling.Tests/DarlingCommandExecutorTests.cs","Darling/Darling.Tests/DarlingMcpStoreMetricsToolsTests.cs","Darling/Darling.Tests/DarlingRetentionTests.cs","Darling/Darling.Tests/PurgeNowBackgroundTests.cs","Darling/Darling.Tests/PurgeNowTotalsWatchTests.cs","Darling/Darling.Tests/QueryStoreIntervalEndRungTests.cs","Darling/Darling.Tests/RawPurgeNowWiringTests.cs","Darling/Darling.Tests/RawTablesLeaveCatalogSweepLiveTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/RetentionWalPacerLiveTests.cs","Darling/Darling.Tests/RetentionWalPacerTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/PerformanceMonitor.Darling.Service/CheckpointSyncSampler.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingRetention.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpStoreMetricsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoreMetricsReader.cs","Darling/PerformanceMonitor.Darling.Service/RetentionWalPacer.cs","Darling/PerformanceMonitor.Darling.Service/ServiceCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreSelfMetrics.cs","Darling/PerformanceMonitor.Darling.Viewer/PurgeNowWatch.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ControlCommands.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs"],"failed_classes":["Darling.Tests.PostmasterStartTimeLivePostgresTests","Darling.Tests.QueryStoreIntervalEndRungTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"374b6af4c50efccecaa50c34437345eb81513017","event":"pull_request","changed_files":["Darling/Darling.Tests/ConfigChangeTraceAnchorTests.cs","Darling/Darling.Tests/CreationTimeClockFrameDisciplineTests.cs","Darling/Darling.Tests/ParameterSensitivityClockFrameLiveTests.cs","Darling/Darling.Tests/ParameterSensitivityFiringSignatureParityTests.cs","Darling/Darling.Tests/PlanRegressionReadShapeTests.cs","Darling/Darling.Tests/ServerLocalReadFrameDisciplineTests.cs","Darling/Darling.Tests/ServerLocalRowConversionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.QueryPerf.cs","Darling/PerformanceMonitor.Darling.Analysis/ServerLocalTimes.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs","Lite.Tests/ServerClockDstReadTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/DrillDownCollector.Queries.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","Lite/Analysis/PlanCreationClock.cs","Lite/Services/LocalDataService.RunningJobs.cs"],"failed_classes":["Darling.Tests.ParameterSensitiveDrillDownTextLiveTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"6634aa7a1dff35234981389d3be04eca73a811b2","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthAggregateTests.cs","Darling/Darling.Tests/FleetPartialDatabaseFailureBandTests.cs","Darling/Darling.Tests/OverviewCollectionHealthRollupRoutingTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Storage/CollectionHealthRollupSupport.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs"],"failed_classes":["Darling.Tests.FleetPartialDatabaseFailureBandTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-29","head_sha":"267d275488163ae48377eee077766e723206dce2","event":"pull_request","changed_files":["Darling/Darling.Tests/CountFamilyZeroHistoryTests.cs","Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs","Darling/Darling.Tests/PgTargetAnomalyTests.cs","Darling/Darling.Tests/PgTargetZeroHistoryTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.WaitsSampled.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetAnomalyDetector.cs","Lite.Tests/CountFamilyZeroHistoryTests.cs","Lite.Tests/ScenarioTests.cs","Lite/Analysis/AnomalyDetector.cs","PerformanceMonitor.Analysis/Baselines/CountFamilyMetadata.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Anomaly.cs","PerformanceMonitor.Analysis/PgTargetAdvice.Wait.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-29","head_sha":"58811dedb7e50d3cd038c17f3b23a1981db07754","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/CorrelatedLanesXAxisWindowTests.cs","Darling/Darling.Tests/ViewerAlertClockCacheTests.cs","Darling/Darling.Tests/ViewerChartUtcFrameTests.cs","Darling/Darling.Tests/ViewerClockRendererArithmeticTests.cs","Darling/Darling.Tests/ViewerContextMenuTests.cs","Darling/Darling.Tests/ViewerDrillDownTests.cs","Darling/Darling.Tests/ViewerRecommendationServerClockTests.cs","Darling/Darling.Tests/ViewerServerClockTests.cs","Darling/Darling.Tests/ViewerTimeHelperTests.cs","Darling/Darling.Tests/ViewerTimeStaticsCollection.cs","Darling/Darling.Tests/ViewerTypedRangeZoneTests.cs","Darling/Darling.Tests/ViewerWave3Tests.cs","Darling/PerformanceMonitor.Darling.Viewer/CorrelatedTimelineLanesControl.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ManageServersWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ProcedureHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStatsHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/QueryStoreHistoryWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/RecommendationsViewModel.cs","Darling/PerformanceMonitor.Darling.Viewer/ServerClockCache.cs","Darling/PerformanceMonitor.Darling.Viewer/TimeRangeSlicerControl.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Cpu.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ChartContextMenu.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Charts.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CollectionHealth.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.CpuScheduler.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.DrillDown.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.FileIo.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LatchSpinlock.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Perfmon.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.PlanCache.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryHeatmap.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SessionStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemHealthCharts.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.TimeRange.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Waits.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerTimeHelper.cs","Lite.Tests/AlertBadgeServerOffsetTests.cs","Lite.Tests/AlertHistoryRowClockTests.cs","Lite.Tests/AxesExtensionsTests.cs","Lite.Tests/ChartCsvExportDisplayZoneTests.cs","Lite.Tests/ChartTimeConversionClockTests.cs","Lite.Tests/CollectionHealthWindowTests.cs","Lite.Tests/ComparisonWindowUtcTests.cs","Lite.Tests/CustomRangeStateTests.cs","Lite.Tests/DisplayZoneRendererTests.cs","Lite.Tests/DisplayZoneTests.cs","Lite.Tests/DisplayZoneTextFrameTests.cs","Lite.Tests/DisplayZoneTickTests.cs","Lite.Tests/HistoryGridZoneTests.cs","Lite.Tests/LiteRecommendationServerClockTests.cs","Lite.Tests/McpPageContractTests.cs","Lite.Tests/OverviewComparisonWindowOffsetTests.cs","Lite.Tests/OverviewLanesUtcFrameTests.cs","Lite.Tests/QueriesTabWindowRoundTripTests.cs","Lite.Tests/QueryWindowTruncationTests.cs","Lite.Tests/SecondOccurrenceReadTests.cs","Lite.Tests/ServerClockAsTimeZoneTests.cs","Lite.Tests/ServerTabChartUtcFrameTests.cs","Lite.Tests/ServerTabChartWindowTests.cs","Lite.Tests/ServerTabDrillWindowTests.cs","Lite.Tests/ServerTabHeldRangeTests.cs","Lite.Tests/ServerTimeHelperClockTests.cs","Lite.Tests/ServerTimeHelperCollectionTests.cs","Lite.Tests/TimeHonestyRungTests.cs","Lite.Tests/TimeRangeSlicerLabelsTests.cs","Lite.Tests/TimeWindowsTests.cs","Lite.Tests/TypedBoundTests.cs","Lite.Tests/WindowedReadServerClockTests.cs","Lite/Analysis/Recommendations/LiteRecommendationsViewModel.cs","Lite/App.xaml.cs","Lite/Controls/AlertsHistoryTab.xaml.cs","Lite/Controls/CorrelatedTimelineLanesControl.xaml.cs","Lite/Controls/FinOpsTab.xaml.cs","Lite/Controls/RecommendationsTab.xaml.cs","Lite/Controls/ServerTab.BlockChain.cs","Lite/Controls/ServerTab.BlockingStats.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.Comparison.cs","Lite/Controls/ServerTab.ConfigChanges.cs","Lite/Controls/ServerTab.CpuScheduler.cs","Lite/Controls/ServerTab.DailySummary.cs","Lite/Controls/ServerTab.DrillDown.cs","Lite/Controls/ServerTab.Grids.cs","Lite/Controls/ServerTab.LatchSpinlock.cs","Lite/Controls/ServerTab.Pickers.cs","Lite/Controls/ServerTab.PlanCache.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.SessionStats.cs","Lite/Controls/ServerTab.Slicers.cs","Lite/Controls/ServerTab.SystemEvents.cs","Lite/Controls/ServerTab.SystemHealthCharts.cs","Lite/Controls/ServerTab.TimeRange.cs","Lite/Controls/ServerTab.xaml.cs","Lite/Controls/TimeRangeSlicerControl.xaml.cs","Lite/Helpers/ContextMenuHelper.cs","Lite/MainWindow.xaml.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Services/LocalDataService.AlertHistory.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.BlockingStats.cs","Lite/Services/LocalDataService.CollectionHealth.cs","Lite/Services/LocalDataService.ConfigChanges.cs","Lite/Services/LocalDataService.Cpu.cs","Lite/Services/LocalDataService.CpuScheduler.cs","Lite/Services/LocalDataService.Discontinuities.cs","Lite/Services/LocalDataService.FileIo.cs","Lite/Services/LocalDataService.LatchSpinlock.cs","Lite/Services/LocalDataService.LongQueries.cs","Lite/Services/LocalDataService.Memory.cs","Lite/Services/LocalDataService.MemoryGrants.cs","Lite/Services/LocalDataService.Perfmon.cs","Lite/Services/LocalDataService.PlanCache.cs","Lite/Services/LocalDataService.PlanCorrection.cs","Lite/Services/LocalDataService.QueryHeatmap.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.QueryStore.cs","Lite/Services/LocalDataService.QueryStoreRegressions.cs","Lite/Services/LocalDataService.SessionStats.cs","Lite/Services/LocalDataService.SystemEvents.cs","Lite/Services/LocalDataService.TempDb.cs","Lite/Services/LocalDataService.TrendBuckets.cs","Lite/Services/LocalDataService.WaitStats.cs","Lite/Services/LocalDataService.WaitingTasks.cs","Lite/Services/LocalDataService.cs","Lite/Services/ServerTimeHelper.cs","Lite/Windows/ProcedureHistoryWindow.xaml.cs","Lite/Windows/QueryStatsHistoryWindow.xaml.cs","Lite/Windows/QueryStoreHistoryWindow.xaml.cs","Lite/Windows/WaitDrillDownWindow.xaml.cs","PerformanceMonitor.Analysis/Baselines/ServerClock.cs","PerformanceMonitor.Ui/AxesExtensions.cs","PerformanceMonitor.Ui/ChartHoverHelper.cs","PerformanceMonitor.Ui/CorrelatedCrosshairManager.cs","PerformanceMonitor.Ui/CpuSchedulerChartRenderer.cs","PerformanceMonitor.Ui/CustomRangeState.cs","PerformanceMonitor.Ui/DisplayZone.cs","PerformanceMonitor.Ui/DisplayZoneTickGenerator.cs","PerformanceMonitor.Ui/GroupedTrendChartRenderer.cs","PerformanceMonitor.Ui/SessionStatsChartRenderer.cs","PerformanceMonitor.Ui/SystemHealthChartRenderer.cs","PerformanceMonitor.Ui/TimeWindows.cs"],"failed_classes":["Darling.Tests.ConsumedTimestampFrameDisciplineTests","Darling.Tests.PgLogBurstAndRotationLiveTests","Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)","Darling PG tests (2)","build"]} +{"date":"2026-09-30","head_sha":"e01fa13f0137865f98527c36398d4bf0430ba47b","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/CheckpointSyncSamplerTests.cs","Darling/Darling.Tests/ConnectionAlertFailedSendRetryTests.cs","Darling/Darling.Tests/CustomAlertClockStepTests.cs","Darling/Darling.Tests/FailedSendRetryTrackerClockStepTests.cs","Darling/Darling.Tests/FailedSendRetryTrackerTests.cs","Darling/Darling.Tests/FleetGateSignalTests.cs","Darling/Darling.Tests/FleetGateSkipCreditTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/OversizedPlanBacklogPins.cs","Darling/Darling.Tests/PurgeNowBackgroundTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/SelfAlertFailedSendCensusTests.cs","Darling/Darling.Tests/ServerConnectPolicyTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/FleetGateStats.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/OversizedPlanBacklogSweep.cs","Darling/README.md","Lite.Tests/AgAlertFailedSendRetryTests.cs","Lite.Tests/ArchiveResetRetryClockStepTests.cs","Lite.Tests/CollectionCycleResumeTests.cs","Lite.Tests/CollectorGridScheduleTests.cs","Lite.Tests/ConnectionAlertRetryInFlightTests.cs","Lite.Tests/HousekeepingClockStepTests.cs","Lite/MainWindow.xaml.cs","Lite/Services/AgAlertEvaluator.cs","Lite/Services/ArchiveService.cs","Lite/Services/CollectionBackgroundService.cs","Lite/Services/ConnectionAlertSendsInFlight.cs","Lite/Services/RemoteCollectorService.BlockedProcessReport.cs","Lite/Services/RemoteCollectorService.cs","Lite/Services/ScheduleManager.cs","PerformanceMonitor.Alerting/FailedSendRetryTracker.cs","PerformanceMonitor.Collectors/CollectorCadence.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-30","head_sha":"7ed8c5e1f84bd3c91b34154582f96e091d53e37d","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertCooldownClockStepTests.cs","Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/CheckpointSyncSamplerTests.cs","Darling/Darling.Tests/ConnectionAlertFailedSendRetryTests.cs","Darling/Darling.Tests/CustomAlertClockStepTests.cs","Darling/Darling.Tests/FailedSendRetryTrackerClockStepTests.cs","Darling/Darling.Tests/FailedSendRetryTrackerTests.cs","Darling/Darling.Tests/FleetGateSignalTests.cs","Darling/Darling.Tests/FleetGateSkipCreditTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/OversizedPlanBacklogPins.cs","Darling/Darling.Tests/PurgeNowBackgroundTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/SelfAlertFailedSendCensusTests.cs","Darling/Darling.Tests/ServerConnectPolicyTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/FleetGateStats.cs","Darling/PerformanceMonitor.Darling.Service/Hosting/DarlingHttpRefusalLog.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/OversizedPlanBacklogSweep.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogSource.cs","Darling/PerformanceMonitor.Darling.Viewer/AlertToastCoordinator.cs","Darling/README.md","Lite.Tests/AgAlertClockStepTests.cs","Lite.Tests/AgAlertFailedSendRetryTests.cs","Lite.Tests/ArchiveResetRetryClockStepTests.cs","Lite.Tests/CollectionCycleResumeTests.cs","Lite.Tests/CollectorGridScheduleTests.cs","Lite.Tests/ConnectionAlertRetryInFlightTests.cs","Lite.Tests/HousekeepingClockStepTests.cs","Lite/MainWindow.xaml.cs","Lite/Services/AgAlertEvaluator.cs","Lite/Services/ArchiveService.cs","Lite/Services/CollectionBackgroundService.cs","Lite/Services/ConnectionAlertSendsInFlight.cs","Lite/Services/RemoteCollectorService.BlockedProcessReport.cs","Lite/Services/RemoteCollectorService.cs","Lite/Services/ScheduleManager.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/FailedSendRetryTracker.cs","PerformanceMonitor.Collectors/CollectorCadence.cs","PerformanceMonitor.Collectors/PgReadBinaryFileAdvisory.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Common/LastFiredStamp.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/AnalysisNotificationService.cs","PerformanceMonitor.Notifications/IncidentCooldown.cs","PerformanceMonitor.Notifications/RepeatDeliveryBudget.cs"],"failed_classes":["Darling.Tests.PgTargetAnomalyTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-30","head_sha":"8aa1cfbaa1fc300085b6663beb2a3f3cf4eac5a3","event":"pull_request","changed_files":["Darling/Darling.Tests/CountFamilyZeroHistoryTests.cs","Darling/Darling.Tests/DarlingAnomalyBaselineTests.cs","Darling/Darling.Tests/DarlingEventBaselineCoverageReadPlanShapeLiveTests.cs","Darling/Darling.Tests/DarlingEventBaselineCoveredDaysTests.cs","Darling/Darling.Tests/LocalClockBucketKeyTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/PerformanceMonitor.Darling.Analysis/BaselineCache.cs","Darling/PerformanceMonitor.Darling.Analysis/PgBaselineProvider.cs","Lite.Tests/ConnectionThrottlePerAttemptTests.cs","Lite.Tests/CountFamilyZeroHistoryTests.cs","Lite.Tests/EventBaselineCoveredDaysTests.cs","Lite.Tests/LocalClockBucketKeyTests.cs","Lite.Tests/ScenarioTests.cs","Lite.Tests/SqlExceptionFactory.cs","Lite.Tests/SwallowedItemFailureTests.cs","Lite.Tests/TestDataSeeder.cs","Lite.Tests/XeSessionHealthTests.cs","Lite/Analysis/BaselineProvider.cs","Lite/MainWindow.xaml.cs","Lite/Services/LocalDataService.CollectionHealth.cs","Lite/Services/RemoteCollectorService.BlockedProcessReport.cs","Lite/Services/RemoteCollectorService.Deadlocks.cs","Lite/Services/RemoteCollectorService.LongQueryCompletions.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Analysis/Baselines/CountFamilyMetadata.cs"],"failed_classes":["Darling.Tests.DarlingEventBaselineCoverageReadPlanShapeLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-30","head_sha":"93d34a73cde110dc5a2017b3695ed591787fee94","event":"pull_request","changed_files":["Darling/Darling.Tests/AnomalyTileWindowEndTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs","Lite.Tests/AnomalyTileWindowEndTests.cs","Lite/Analysis/AnomalyDetector.cs"],"failed_classes":["Darling.Tests.RollupBackfillLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-30","head_sha":"48481a3766ddd412ae8dfd702eb7fb052044a660","event":"pull_request","changed_files":["Darling/Darling.Tests/RollupBackfillLiveTests.cs"],"failed_classes":["Darling.Tests.RollupBackfillLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-30","head_sha":"e95166c21cc35aeb56fe02faa1485a5cbf0f5b91","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/HourlyWindowEdgesTests.cs","Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs","Darling/Darling.Tests/RollupWindowEndBoundLiveTests.cs","Darling/Darling.Tests/RollupWindowEndBoundTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/DurationTrendRouting.cs","Darling/PerformanceMonitor.Darling.Storage/HourlyWindowEdges.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs"],"failed_classes":["Darling.Tests.RollupWindowEndBoundLiveTests","Darling.Tests.ViewerTrendRoutingLivePostgresTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-09-30","head_sha":"f12072caa9ea6524a401ced7969e355679aac7ba","event":"push","changed_files":["Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/HourlyAttributionSpanTests.cs","Darling/Darling.Tests/HourlyWindowEdgesTests.cs","Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs","Darling/Darling.Tests/RollupWindowEndBoundLiveTests.cs","Darling/Darling.Tests/RollupWindowEndBoundTests.cs","Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Storage/DurationTrendRouting.cs","Darling/PerformanceMonitor.Darling.Storage/HourlyWindowEdges.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreTrendRouting.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs"],"failed_classes":["Darling.Tests.PgTargetDeadlockDrillDownTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-30","head_sha":"3230e3859c276d771fc9990a34903eca8e25fa20","event":"pull_request","changed_files":["Darling/Darling.Tests/ManagedConfFileTests.cs","Darling/Darling.Tests/ManagedConfRehearsalTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedPostgres.cs","Darling/PerformanceMonitor.Darling.Service/ManagedConfFile.cs","Darling/README.md"],"failed_classes":["Darling.Tests.ManagedConfFileLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-09-30","head_sha":"8e13b562d28478554773efd035b2bd05cc740516","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideFirstExecFloorTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs"],"failed_classes":["Darling.Tests.DarlingManagedPostgresTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-09-30","head_sha":"8c850b6fba36f9262a6ffa7640678bd8681350db","event":"push","changed_files":["Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWideBrinIndex.cs"],"failed_classes":["Darling.Tests.DarlingDeployStaleFileTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-09-30","head_sha":"d6c02bf28a397be61b6451a6b68d9565bcc04b13","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedPostgresTests.cs","Darling/Darling.Tests/MoveDirectoryOnceReleasedTests.cs"],"failed_classes":["Darling.Tests.DarlingStoreUpgradeRevertTests"],"failed_jobs":["build"]} +{"date":"2026-09-30","head_sha":"1c0ceba428267369c5ec364a70112e795b43f9ae","event":"pull_request","changed_files":["Lite.Tests/McpToolGuideHeads.AnalyzeProcedurePlan.cs","Lite.Tests/McpToolGuideHeads.AnalyzeQueryPlan.cs","Lite.Tests/McpToolsListBudget/McpPlanTools.txt","Lite.Tests/PlanToolsNotKeptTests.cs","Lite/Mcp/McpPlanTools.cs"],"failed_classes":["Darling.Tests.CollectorEngineCapabilityTests","Darling.Tests.EngineCapabilityReadWiringTests","Darling.Tests.McpToolGuideTests"],"failed_jobs":["build"]} +{"date":"2026-10-01","head_sha":"cd500fb11383cee3a29ff41d04da7c40d2b34817","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs","Darling/Darling.Tests/AzureSqlDatabaseHostLeftoversTests.cs","Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs","Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js","Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Lite.Tests/AzureSqlDatabaseHardwareTests.cs","Lite.Tests/AzureSqlDatabaseHostLeftoversTests.cs","Lite.Tests/AzureSqlDatabaseHostMathTests.cs","Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/MemoryCollectorsDefinitionTests.cs","Lite.Tests/TestDataSeeder.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Analysis/DuckDbFactCollector.Config.cs","Lite/Analysis/DuckDbFactCollector.Resources.cs","Lite/Controls/FinOpsTab.xaml","Lite/Controls/FinOpsTab.xaml.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpMemoryTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Mcp/McpServerInfoTools.cs","Lite/Services/LocalDataService.FinOps.ServerProperties.cs","Lite/Services/LocalDataService.FinOps.Utilization.cs","Lite/Services/LocalDataService.FinOps.cs","Lite/Services/LocalDataService.Memory.cs","Lite/Services/LocalDataService.PlanServerMetadata.cs","Lite/Services/LocalDataService.ServerInfo.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactCollectorHelpers.cs","PerformanceMonitor.Analysis/FactRemediation.cs","PerformanceMonitor.Collectors/MemoryStatsCollector.cs","PerformanceMonitor.Common/CpuAttribution.cs","PerformanceMonitor.Common/ProvisioningVerdict.cs","PerformanceMonitor.Common/ServerHardwareScope.cs","PerformanceMonitor.PlanAnalysis/ServerContextCard.cs","PerformanceMonitor.PlanAnalysis/ServerMetadata.cs"],"failed_classes":["Darling.Tests.DarlingMcpDataToolsLivePostgresTests","Darling.Tests.ViewerMemoryLivePostgresTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"111f91096d9fd1da0ebd0c97c7e36eba846810e3","event":"pull_request","changed_files":["Lite.Tests/ArchiveResetRestoresStateTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.ArchiveResetRestoresStateTests"],"failed_jobs":["Lite tests (0)"]} +{"date":"2026-10-01","head_sha":"67bf55c45aa76ce927c4ca8173bb32c97547031b","event":"pull_request","changed_files":["Lite.Tests/ArchivePreservedStateTablesTests.cs","Lite.Tests/ArchiveResetRestoresStateTests.cs","Lite.Tests/ArchiveWatermarkCacheTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/ArchiveService.cs","Lite/Services/ArchiveWatermarkCache.cs","Lite/Services/RemoteCollectorService.cs"],"failed_classes":["Darling.Tests.ClipboardTextRetryBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-10-01","head_sha":"3b79ba261ac8f37fba0dbb88e13e7fe233300b95","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs","Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs","Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js","Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Lite.Tests/AuditConfigEditionTests.cs","Lite.Tests/AzureSqlDatabaseHardwareTests.cs","Lite.Tests/AzureSqlDatabaseHostMathTests.cs","Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/MemoryCollectorsDefinitionTests.cs","Lite.Tests/TestDataSeeder.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Analysis/DuckDbFactCollector.Config.cs","Lite/Analysis/DuckDbFactCollector.Resources.cs","Lite/Controls/FinOpsTab.xaml","Lite/Controls/FinOpsTab.xaml.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpMemoryTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Mcp/McpServerInfoTools.cs","Lite/Services/LocalDataService.FinOps.ServerProperties.cs","Lite/Services/LocalDataService.FinOps.Utilization.cs","Lite/Services/LocalDataService.FinOps.cs","Lite/Services/LocalDataService.Memory.cs","Lite/Services/LocalDataService.PlanServerMetadata.cs","Lite/Services/LocalDataService.ServerInfo.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactCollectorHelpers.cs","PerformanceMonitor.Analysis/FactRemediation.cs","PerformanceMonitor.Collectors/MemoryStatsCollector.cs","PerformanceMonitor.Common/CpuAttribution.cs","PerformanceMonitor.Common/ProvisioningVerdict.cs","PerformanceMonitor.Common/ServerHardwareScope.cs","PerformanceMonitor.PlanAnalysis/ServerContextCard.cs","PerformanceMonitor.PlanAnalysis/ServerMetadata.cs"],"failed_classes":["Darling.Tests.DarlingMcpDataToolsLivePostgresTests","Darling.Tests.ViewerMemoryLivePostgresTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-10-01","head_sha":"4bc2f89822bd877607d3231febdcc28956ae136f","event":"pull_request","changed_files":["Darling/Darling.Tests/ClipboardTextRetryBudgetTests.cs","Lite.Tests/ArchivePreservedStateTablesTests.cs","Lite.Tests/ArchiveResetRestoreByNameTests.cs","Lite.Tests/ArchiveResetRestoresStateTests.cs","Lite.Tests/ArchiveWatermarkCacheTests.cs","Lite.Tests/ArchiveWatermarkGreaterOfTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/ArchiveService.cs","Lite/Services/ArchiveWatermarkCache.cs","Lite/Services/RemoteCollectorService.cs"],"failed_classes":["PerformanceMonitorLite.Tests.ArchiveResetRestoreByNameTests","PerformanceMonitorLite.Tests.ArchiveWatermarkGreaterOfTests"],"failed_jobs":["Lite tests (1)","Lite tests (3)"]} +{"date":"2026-10-01","head_sha":"642824844ce83c1b51046f2b32248d1d21c39e14","event":"pull_request","changed_files":["Darling/Darling.Tests/ClipboardTextRetryBudgetTests.cs","Lite.Tests/ArchivePreservedStateTablesTests.cs","Lite.Tests/ArchiveResetRestoreByNameTests.cs","Lite.Tests/ArchiveResetRestoresStateTests.cs","Lite.Tests/ArchiveWatermarkCacheTests.cs","Lite.Tests/ArchiveWatermarkGreaterOfTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/ArchiveService.cs","Lite/Services/ArchiveWatermarkCache.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs","Lite/Services/RemoteCollectorService.cs"],"failed_classes":["Lite.Tests.QueryStoreBackfillReadFailureWarningTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-10-01","head_sha":"6ad569e59da599df5925a1adc81ec73c5ad5a902","event":"pull_request","changed_files":["Darling/Darling.Tests/ClipboardTextRetryBudgetTests.cs","Lite.Tests/ArchivePreservedStateTablesTests.cs","Lite.Tests/ArchiveResetRestoreByNameTests.cs","Lite.Tests/ArchiveResetRestoresStateTests.cs","Lite.Tests/ArchiveWatermarkCacheTests.cs","Lite.Tests/ArchiveWatermarkGreaterOfTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/ArchiveService.cs","Lite/Services/ArchiveWatermarkCache.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs","Lite/Services/RemoteCollectorService.cs"],"failed_classes":["Lite.Tests.QueryStoreBackfillReadFailureWarningTests","PerformanceMonitorLite.Tests.ArchiveResetRestoresStateTests"],"failed_jobs":["Lite tests (1)","Lite tests (0)"]} +{"date":"2026-10-01","head_sha":"65416cbea5f61a2b0d098c3d3dedd2b09a54d85b","event":"push","changed_files":["Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/MeasurementContractCensusTests.cs","Darling/Darling.Tests/PerfmonCounterTypeRungTests.cs","Darling/Darling.Tests/PerfmonPerSecondPayloadTests.cs","Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs","Darling/Darling.Tests/web-perfmon-harness.mjs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Lite.Tests/DeltaSeriesShapingTests.cs","Lite.Tests/McpToolsListBudget/McpPerfmonTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/MeasurementContractCensusTests.cs","Lite.Tests/PerfmonCounterTypeTests.cs","Lite.Tests/PerfmonPerSecondRoundingTests.cs","Lite/Mcp/McpPerfmonTools.cs","Lite/Services/LocalDataService.Perfmon.cs","PerformanceMonitor.Common/DeltaSeriesShaping.cs","PerformanceMonitor.Common/Mcp/TrendPayloads.cs"],"failed_classes":["Darling.Tests.ClipboardTextRetryBudgetTests"],"failed_jobs":["build"]} +{"date":"2026-10-01","head_sha":"a6cdbd8c1e9896c64cc242729e4409e04d96ab8e","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs","Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs","Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js","Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Lite.Tests/AuditConfigEditionTests.cs","Lite.Tests/AzureSqlDatabaseHardwareTests.cs","Lite.Tests/AzureSqlDatabaseHostMathTests.cs","Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/MemoryCollectorsDefinitionTests.cs","Lite.Tests/TestDataSeeder.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Analysis/DuckDbFactCollector.Config.cs","Lite/Analysis/DuckDbFactCollector.Resources.cs","Lite/Controls/FinOpsTab.xaml","Lite/Controls/FinOpsTab.xaml.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpMemoryTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Mcp/McpServerInfoTools.cs","Lite/Services/LocalDataService.FinOps.Recommendations.cs","Lite/Services/LocalDataService.FinOps.ServerProperties.cs","Lite/Services/LocalDataService.FinOps.Utilization.cs","Lite/Services/LocalDataService.FinOps.cs","Lite/Services/LocalDataService.Memory.cs","Lite/Services/LocalDataService.PlanServerMetadata.cs","Lite/Services/LocalDataService.ServerInfo.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactCollectorHelpers.cs","PerformanceMonitor.Analysis/FactRemediation.cs","PerformanceMonitor.Collectors/MemoryStatsCollector.cs","PerformanceMonitor.Common/CpuAttribution.cs","PerformanceMonitor.Common/ProvisioningVerdict.cs","PerformanceMonitor.Common/ServerHardwareScope.cs","PerformanceMonitor.PlanAnalysis/ServerContextCard.cs","PerformanceMonitor.PlanAnalysis/ServerMetadata.cs"],"failed_classes":["Darling.Tests.ViewerFinOpsRecommendationsTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"32505f5632b5fcc67e7d10ace30dd74f496c1304","event":"pull_request","changed_files":["Darling/Darling.Tests/ClipboardTextRetryBudgetTests.cs","Lite.Tests/ArchivePreservedStateTablesTests.cs","Lite.Tests/ArchiveResetRestoreByNameTests.cs","Lite.Tests/ArchiveResetRestoresStateTests.cs","Lite.Tests/ArchiveWatermarkCacheTests.cs","Lite.Tests/ArchiveWatermarkGreaterOfTests.cs","Lite.Tests/QueryStoreBackfillReadFailureWarningTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/ArchiveService.cs","Lite/Services/ArchiveWatermarkCache.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Ui/ClipboardText.cs"],"failed_classes":["PerformanceMonitorLite.Tests.ArchiveWatermarkGreaterOfTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-10-01","head_sha":"aaa855bb1f888df3f9f0218aa873a903fb117d26","event":"pull_request","changed_files":["Darling/Darling.Tests/ClipboardTextRetryBudgetTests.cs","Lite.Tests/ArchivePreservedStateTablesTests.cs","Lite.Tests/ArchiveResetRestoreByNameTests.cs","Lite.Tests/ArchiveResetRestoresStateTests.cs","Lite.Tests/ArchiveWatermarkCacheTests.cs","Lite.Tests/ArchiveWatermarkGreaterOfTests.cs","Lite.Tests/QueryStoreBackfillReadFailureWarningTests.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/PreservedTableRestore.cs","Lite/Services/ArchiveService.cs","Lite/Services/ArchiveWatermarkCache.cs","Lite/Services/DuckDbAlertHistoryStore.cs","Lite/Services/RemoteCollectorService.QueryStoreBackfill.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Ui/ClipboardText.cs"],"failed_classes":["Darling.Tests.SingleFlightTtlCacheTests","PerformanceMonitorLite.Tests.ArchiveResetRestoresStateTests","PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["build","Lite tests (0)","Lite tests (2)"]} +{"date":"2026-10-01","head_sha":"6fae165d3ecca4418d3b8e7ff9b3ab252239ef64","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs","Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs","Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Darling/Darling.Tests/DarlingReadsUseOnlyStoreViewsTests.cs","Darling/Darling.Tests/TimeHonestyRungTests.cs","Darling/Darling.Tests/ViewerAzureSqlDatabaseCallSiteTests.cs","Darling/Darling.Tests/ViewerFinOpsRecommendationsTests.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Config.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Resources.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js","Darling/PerformanceMonitor.Darling.Storage/DarlingServerMetadataReader.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Memory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Lite.Tests/AuditConfigEditionTests.cs","Lite.Tests/AzureSqlDatabaseCallSiteTests.cs","Lite.Tests/AzureSqlDatabaseHardwareTests.cs","Lite.Tests/AzureSqlDatabaseHostMathTests.cs","Lite.Tests/AzureSqlDatabaseMemoryScopeTests.cs","Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/MemoryCollectorsDefinitionTests.cs","Lite.Tests/TestDataSeeder.cs","Lite/Analysis/DrillDownCollector.Plans.cs","Lite/Analysis/DuckDbFactCollector.Config.cs","Lite/Analysis/DuckDbFactCollector.Resources.cs","Lite/Controls/FinOpsTab.xaml","Lite/Controls/FinOpsTab.xaml.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpAnalysisTools.cs","Lite/Mcp/McpMemoryTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Mcp/McpServerInfoTools.cs","Lite/Services/LocalDataService.FinOps.Recommendations.cs","Lite/Services/LocalDataService.FinOps.ServerProperties.cs","Lite/Services/LocalDataService.FinOps.Utilization.cs","Lite/Services/LocalDataService.FinOps.cs","Lite/Services/LocalDataService.Memory.cs","Lite/Services/LocalDataService.PlanServerMetadata.cs","Lite/Services/LocalDataService.ServerInfo.cs","PerformanceMonitor.Analysis/FactAdvice.cs","PerformanceMonitor.Analysis/FactCollectorHelpers.cs","PerformanceMonitor.Analysis/FactRemediation.cs","PerformanceMonitor.Collectors/MemoryStatsCollector.cs","PerformanceMonitor.Common/CpuAttribution.cs","PerformanceMonitor.Common/ProvisioningVerdict.cs","PerformanceMonitor.Common/ServerHardwareScope.cs","PerformanceMonitor.PlanAnalysis/ServerContextCard.cs","PerformanceMonitor.PlanAnalysis/ServerMetadata.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-10-01","head_sha":"5a9f598d9240f7222846ce6a7c89dd0055b7f085","event":"pull_request","changed_files":["Lite.Tests/ArchiveResetExportTests.cs","Lite.Tests/ArchiveResetRestoresStateTests.cs","Lite.Tests/ArchiveResetRetryClockStepTests.cs","Lite.Tests/ArchiveViewRefreshAfterDeleteTests.cs","Lite.Tests/MuteRuleServiceTests.cs","Lite.Tests/MuteRuleWindowRefusalTests.cs","Lite.Tests/SharedDuckDbFixture.cs","Lite/Analysis/FindingStore.cs","Lite/Controls/AlertsHistoryTab.xaml.cs","Lite/Controls/SnoozeBalloon.xaml.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/PreservedTableRestore.cs","Lite/Helpers/PendingRestoreNotice.cs","Lite/Services/ArchiveService.cs","Lite/Services/DuckDbMuteRuleStore.cs","Lite/Services/LocalDataService.AlertHistory.cs","Lite/Services/LocalDataService.ServerTags.cs","Lite/Windows/ManageMuteRulesWindow.xaml.cs"],"failed_classes":["Lite.Tests.MuteRuleWindowRefusalTests","PerformanceMonitorLite.Tests.MuteRuleServiceTests"],"failed_jobs":["Lite tests (0)","Lite tests (2)"]} +{"date":"2026-10-01","head_sha":"bd66895a04c5e51c398b9140f3549c1084e3aba1","event":"pull_request","changed_files":["Darling/Darling.Tests/DatabaseFileGrowthReadTests.cs","Lite.Tests/FileGrowthReadTests.cs"],"failed_classes":["Darling.Tests.DatabaseFileGrowthReadTests","Lite.Tests.FileGrowthReadTests"],"failed_jobs":["Darling PG tests (0)","Lite tests (1)","Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"9e2a85a6f2fd46284d7fa33dbe015b607e648cff","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureYoungBaselineWaitBarTests.cs","Lite.Tests/AnomalyDetectorTests.cs"],"failed_classes":["Darling.Tests.AzureYoungBaselineWaitBarTests","PerformanceMonitorLite.Tests.AnomalyDetectorTests"],"failed_jobs":["Lite tests (2)","Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"4f25bdea23951ac22453eff82607b3eb0cc88753","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AzureMasterScopeTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Lite.Tests/McpPageContractTests.cs","PerformanceMonitor.Alerting/AlertServerSnapshot.cs","PerformanceMonitor.Alerting/AzureMasterScope.cs"],"failed_classes":["Darling.Tests.AlertEngineTests","Darling.Tests.AzureMasterScopeTests","Darling.Tests.DarlingMcpBlockingToolsLivePostgresTests","Darling.Tests.DarlingMcpBlockingToolsSurfaceAndSqlTests","PerformanceMonitorLite.Tests.McpPageContractTests"],"failed_jobs":["build","Darling PG tests (2)","Lite tests (3)","Darling PG tests (0)"]} +{"date":"2026-10-01","head_sha":"2cbbde7306120aab122458764ba61e13331ba2c1","event":"pull_request","changed_files":["Darling/Darling.Tests/NoServiceObjectiveToResizeTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/TestDataSeeder.cs","PerformanceMonitor.Common/ProvisioningVerdict.cs","PerformanceMonitor.Common/ServerHardwareScope.cs"],"failed_classes":["Darling.Tests.NoServiceObjectiveToResizeTests"],"failed_jobs":["build","Darling PG tests (0)","Lite tests (2)"]} +{"date":"2026-10-01","head_sha":"376ffbf00b3e347f3c956c8a7f89e33dc0ac293d","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AzureMasterScopeTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Lite.Tests/McpPageContractTests.cs","Lite/MainWindow.AlertEngine.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Services/LocalDataService.Blocking.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/AlertServerSnapshot.cs","PerformanceMonitor.Alerting/AzureMasterScope.cs"],"failed_classes":["PerformanceMonitorLite.Tests.TimeHonestyRungTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-10-01","head_sha":"558a66c1466a135688c7a0789a128b369538a410","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs","Darling/Darling.Tests/alert-notebook-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-tables.js"],"failed_classes":["Darling.Tests.AlertNotebookTableCatalogTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"0d615203ba66a7d44b66ec32ba5bcf82f9757cb1","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerFinOpsPickerRefreshPinTests.cs","Lite.Tests/FinOpsSizeGridReloadPinTests.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests","Darling.Tests.ViewerFinOpsPickerRefreshPinTests","Lite.Tests.FinOpsSizeGridReloadPinTests"],"failed_jobs":["build","Darling PG tests (2)","Lite tests (0)","Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"b36b9747af170930c58b1a3b2574936dcc5203d0","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Darling/Darling.Tests/NoServiceObjectiveToResizeTests.cs","Darling/Darling.Tests/ServerInventoryFleetMetricsTests.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/TestDataSeeder.cs","Lite/Controls/FinOpsTab.xaml.cs","Lite/Services/LocalDataService.FinOps.Recommendations.cs","Lite/Services/LocalDataService.FinOps.ServerProperties.cs","Lite/Services/LocalDataService.FinOps.Utilization.cs","Lite/Services/LocalDataService.FinOps.cs","PerformanceMonitor.Common/ProvisioningVerdict.cs","PerformanceMonitor.Common/ServerHardwareScope.cs"],"failed_classes":["Darling.Tests.ServerInventoryFleetMetricsTests"],"failed_jobs":["build","Darling PG tests (0)","Lite tests (2)"]} +{"date":"2026-10-01","head_sha":"4afd2c21ab645a06e04cab652ebd43c1525bfb50","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AzureMasterLiveTargetsTests.cs","Darling/Darling.Tests/AzureMasterScopeTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertTemplates.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Lite.Tests/AzureMasterLiteWiringTests.cs","Lite.Tests/McpPageContractTests.cs","Lite/MainWindow.AlertEngine.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Services/LocalDataService.Blocking.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/AlertServerSnapshot.cs","PerformanceMonitor.Alerting/AzureMasterScope.cs"],"failed_classes":["PerformanceMonitorLite.Tests.TimeHonestyRungTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-10-01","head_sha":"3d01c908b746940e3a3c37badfe31ded8af029c9","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertNotebookRenderClientTests.cs","Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs","Darling/Darling.Tests/alert-notebook-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-tables.js"],"failed_classes":["Darling.Tests.AlertNotebookTableCatalogTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"d33e310c66b4dac2d0fdd59b781459cdd46f6f3b","event":"pull_request","changed_files":["Darling/Darling.Tests/DeadlocksTelemetryCursorTests.cs","Lite.Tests/DeadlocksTelemetryCursorLiteTests.cs"],"failed_classes":["Darling.Tests.DeadlocksTelemetryCursorTests","Lite.Tests.DeadlocksTelemetryCursorLiteTests"],"failed_jobs":["Darling PG tests (1)","Lite tests (0)","build"]} +{"date":"2026-10-01","head_sha":"332eb85a65c35fa7a4b60994f1424d86e39802b0","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalysisHistoryGateTests.cs","Lite.Tests/AnalysisDataSpanTests.cs","Lite/Analysis/AnalysisService.cs","PerformanceMonitor.Analysis/AnalysisHistoryGate.cs"],"failed_classes":["Darling.Tests.AnalysisHistoryGateTests","PerformanceMonitorLite.Tests.AnalysisDataSpanTests"],"failed_jobs":["build","Darling PG tests (0)","Lite tests (3)"]} +{"date":"2026-10-01","head_sha":"68eb108f1655c4531925e366ccd89621d5466ea7","event":"pull_request","changed_files":["Darling/Darling.Tests/StorageGrowthNoHistoryLiveTests.cs","Lite.Tests/AzureSiblingGrowthTests.cs"],"failed_classes":["Darling.Tests.StorageGrowthNoHistoryLiveTests","Lite.Tests.AzureSiblingGrowthTests"],"failed_jobs":["Lite tests (2)","Darling PG tests (0)"]} +{"date":"2026-10-01","head_sha":"fdb481b9da70352fb73239167dba2b90ea53c247","event":"pull_request","changed_files":["Lite.Tests/AzureSqlDatabaseNotCollectedSurfaceTests.cs","Lite/Controls/FinOpsTab.xaml","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.ConfigChanges.cs","Lite/Controls/ServerTab.EngineGaps.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.SystemEvents.cs","Lite/Controls/ServerTab.xaml","Lite/Controls/ServerTab.xaml.cs","Lite/Windows/DatabaseStateOverridesWindow.xaml","Lite/Windows/DatabaseStateOverridesWindow.xaml.cs"],"failed_classes":["Darling.Tests.ViewerScopedPaintGuardTests"],"failed_jobs":["build"]} +{"date":"2026-10-01","head_sha":"495d09d85e2c719e5a512d883316b7882c57de9b","event":"pull_request","changed_files":["Darling/Darling.Tests/RightSizingWindowTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/TestDataSeeder.cs","PerformanceMonitor.Common/RightSizingWindow.cs"],"failed_classes":["PerformanceMonitorLite.Tests.FinOpsTests"],"failed_jobs":["build","Lite tests (2)","Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"1eb31abc6367d36d7f9455136b1cd46a7d84aab2","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertEngineTests.cs","Darling/Darling.Tests/AzureMasterAnalysisScopeLiveTests.cs","Darling/Darling.Tests/AzureMasterLiveTargetsTests.cs","Darling/Darling.Tests/AzureMasterScopeTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/Darling.Tests/DeadlockGraphDatabasesTests.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertTemplates.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Lite.Tests/AzureMasterAnalysisScopeTests.cs","Lite.Tests/AzureMasterLiteWiringTests.cs","Lite.Tests/McpPageContractTests.cs","Lite.Tests/TimeHonestyRungTests.cs","Lite/MainWindow.AlertEngine.cs","Lite/Mcp/McpBlockingTools.cs","Lite/Services/LocalDataService.Blocking.cs","PerformanceMonitor.Alerting/AlertContextBuilders.cs","PerformanceMonitor.Alerting/AlertEngine.cs","PerformanceMonitor.Alerting/AlertServerSnapshot.cs","PerformanceMonitor.Alerting/AzureMasterScope.cs","PerformanceMonitor.Analysis/AnalysisContext.cs","PerformanceMonitor.Common/DeadlockGraphDatabases.cs"],"failed_classes":["Darling.Tests.AzureMasterAnalysisScopeLiveTests","PerformanceMonitorLite.Tests.AzureMasterAnalysisScopeTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (0)"]} +{"date":"2026-10-01","head_sha":"8bc022d66d619ac5333922c1100e8700b7a200e7","event":"pull_request","changed_files":["Darling/Darling.Tests/DatabaseConfigOptimizedLockingTests.cs","Darling/Darling.Tests/OptimizedLockingNoteTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCurrentConfigReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Config.cs","Lite.Tests/OptimizedLockingNoteTests.cs","Lite/Services/LocalDataService.Config.cs","PerformanceMonitor.Common/OptimizedLockingNote.cs"],"failed_classes":["Darling.Tests.DatabaseConfigOptimizedLockingGateTests","Darling.Tests.DatabaseConfigOptimizedLockingLivePostgresTests","Darling.Tests.OptimizedLockingNoteLivePostgresTests","Darling.Tests.OptimizedLockingNoteSurfacePinTests","PerformanceMonitorLite.Tests.OptimizedLockingNoteTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)","Darling PG tests (2)","Lite tests (0)"]} +{"date":"2026-10-01","head_sha":"950bb7dbbdcbdbeb23cdae6e829ff80c83ab2bb5","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Darling/Darling.Tests/NoServiceObjectiveToResizeTests.cs","Darling/Darling.Tests/ServerInventoryFleetMetricsTests.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Loaders.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Recommendations.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Inventory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Recommendations.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Utilization.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Lite.Tests/AzureSqlDatabaseMasterPinsTests.cs","Lite.Tests/AzureSqlDatabaseOwnFiguresTests.cs","Lite.Tests/FinOpsTests.cs","Lite.Tests/TestDataSeeder.cs","Lite/Controls/FinOpsTab.xaml.cs","Lite/Services/LocalDataService.FinOps.Recommendations.cs","Lite/Services/LocalDataService.FinOps.ServerProperties.cs","Lite/Services/LocalDataService.FinOps.Utilization.cs","Lite/Services/LocalDataService.FinOps.cs","PerformanceMonitor.Common/ProvisioningVerdict.cs","PerformanceMonitor.Common/ServerHardwareScope.cs"],"failed_classes":["Darling.Tests.DarlingUpgradeInstallTreeWaitTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"de3511050a400d15760045235d9b20f60e50bee5","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertNotebookRenderClientTests.cs","Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs","Darling/Darling.Tests/RawWindowFloorViewerPortTests.cs","Darling/Darling.Tests/ViewTemplateLiteralReader.cs","Darling/Darling.Tests/ViewTemplatesTests.cs","Darling/Darling.Tests/alert-notebook-harness.mjs","Darling/Darling.Tests/read-fields-resolve-harness.mjs","Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js"],"failed_classes":["Darling.Tests.AlertNotebookTableCatalogTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"bca273d76ed26dbed1b1f2bd4025d22fa7a821b2","event":"pull_request","changed_files":["Darling/Darling.Tests/DeadlockDuplicateCleanupLiveTests.cs","Darling/Darling.Tests/DeadlockStoredIdentityTests.cs","Darling/Darling.Tests/DeadlocksTelemetryCursorStateLiveTests.cs","Darling/Darling.Tests/DeadlocksTelemetryCursorTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DeadlockDuplicateCleanup.cs","Lite.Tests/ArchiveViewDedupTests.cs","Lite.Tests/AzureDeadlockTelemetryTests.cs","Lite.Tests/DeadlockDuplicateCleanupTests.cs","Lite.Tests/DeadlockStoredIdentityTests.cs","Lite.Tests/DeadlocksTelemetryCursorLiteTests.cs","Lite.Tests/XeShredGateTests.cs","Lite/Database/DeadlockDuplicateCleanup.cs","Lite/Services/RemoteCollectorService.DefinitionRunner.cs","PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/DeadlocksCollector.cs","PerformanceMonitor.Collectors/IStoredIdentityDedupedCollector.cs"],"failed_classes":["Darling.Tests.DeadlockDuplicateCleanupLiveTests","Darling.Tests.DeadlockStoredIdentityTests","Darling.Tests.TsqlConventionGuardTests","Lite.Tests.DeadlockStoredIdentityTests","PerformanceMonitorLite.Tests.ArchiveViewDedupTests","PerformanceMonitorLite.Tests.DeadlockDuplicateCleanupTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (2)","Lite tests (3)"]} +{"date":"2026-10-01","head_sha":"a07f238b146685b675f6a60ccd7b4b6d5f53870b","event":"pull_request","changed_files":["Lite.Tests/AzureSqlDatabaseNotCollectedSurfaceTests.cs","Lite.Tests/EngineGapNoteTests.cs","Lite/Controls/FinOpsTab.xaml","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.ConfigChanges.cs","Lite/Controls/ServerTab.EngineGaps.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.SystemEvents.cs","Lite/Controls/ServerTab.xaml","Lite/Controls/ServerTab.xaml.cs","Lite/MainWindow.xaml.cs","Lite/Mcp/McpJobTools.cs","Lite/Services/LocalDataService.CollectorRuns.cs","Lite/Windows/DatabaseStateOverridesWindow.xaml","Lite/Windows/DatabaseStateOverridesWindow.xaml.cs"],"failed_classes":["Darling.Tests.ViewerScopedPaintGuardTests"],"failed_jobs":["build"]} +{"date":"2026-10-01","head_sha":"85808894e250f64b3d33f621990a45c4a98cf367","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionNoteLatestRunTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpHealthTools.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Common/ServerHealthBands.cs"],"failed_classes":["PerformanceMonitor.Darling.Tests.RetiredBaselineAggregateLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"e59a01353b9d24a0a9920e40f4ed54098b7d9978","event":"pull_request","changed_files":["Darling/Darling.Tests/RuntimePreconditionMissTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingRuntimePrecondition.cs","Lite.Tests/McpMissMessageParityPinTests.cs","Lite.Tests/McpStatusEnvelopeTests.cs","Lite/Mcp/McpJobTools.cs","Lite/Mcp/McpRuntimePrecondition.cs","Lite/Services/LocalDataService.RuntimePrecondition.cs","PerformanceMonitor.Collectors/CollectorRuntimePrecondition.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"6406f8dfa9ac8f5bc54612e137148fe3319d3987","event":"pull_request","changed_files":["Darling/Darling.Tests/AnalyzeServerPerCallAnalysisServiceTests.cs","Darling/Darling.Tests/AnomalyTileWindowEndTests.cs","Darling/Darling.Tests/AzureMasterAnalysisScopeLiveTests.cs","Darling/Darling.Tests/BlockingChainReadsFloorTests.cs","Darling/Darling.Tests/DeadlockGraphDatabasesTests.cs","Darling/Darling.Tests/EventTimeAnalysisReadsLiveTests.cs","Darling/Darling.Tests/FleetSweepWebFeedTests.cs","Darling/Darling.Tests/PgDeadlockRemaskTests.cs","Darling/Darling.Tests/PgTargetDeadlockDrillDownTests.cs","Darling/Darling.Tests/SharedBaselineCacheTests.cs","Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs","Darling/PerformanceMonitor.Darling.Analysis/PgAnomalyDetector.cs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Blocking.cs","Darling/PerformanceMonitor.Darling.Analysis/PgFactCollector.Waits.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetDrillDownCollector.Deadlocks.cs","Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Database.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingPgDeadlockReader.cs","Lite.Tests/AnomalyTileWindowEndTests.cs","Lite.Tests/AzureMasterAnalysisScopeTests.cs","Lite.Tests/EventBaselineCoveredDaysTests.cs","Lite.Tests/EventTimeAnalysisReadsTests.cs","Lite.Tests/LocalClockBucketKeyTests.cs","Lite/Analysis/AnalysisService.cs","Lite/Analysis/AnomalyDetector.cs","Lite/Analysis/BaselineProvider.cs","Lite/Analysis/DrillDownCollector.Blocking.cs","Lite/Analysis/DuckDbFactCollector.Waits.cs","Lite/Analysis/SeparatelyMonitoredScope.cs","Lite/MainWindow.xaml.cs","PerformanceMonitor.Alerting/AlertContextBuilders.cs","PerformanceMonitor.Analysis/AnalysisContext.cs","PerformanceMonitor.Common/DeadlockGraphDatabases.cs"],"failed_classes":["Darling.Tests.DarlingEventBaselineCoveredDaysTests","Darling.Tests.PostgresEngineGateBehaviorTests","Darling.Tests.TsqlConventionGuardTests","PerformanceMonitorLite.Tests.AzureMasterAnalysisScopeTests"],"failed_jobs":["Darling PG tests (1)","Darling PG tests (2)","Darling PG tests (0)","Lite tests (0)","build"]} +{"date":"2026-10-01","head_sha":"0c983ef54d6bc6d796353b3ddfe1c120e4c1af68","event":"pull_request","changed_files":["Darling/Darling.Tests/DailyStitchLiveTests.cs","Darling/Darling.Tests/DailySummaryAndComposeStitchedLiveTests.cs","Darling/Darling.Tests/DailySummaryNotCarriedTests.cs","Darling/Darling.Tests/DailySummaryReadShapeTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/Darling.Tests/EventTimeWindowReadsLiveTests.cs","Darling/Darling.Tests/EventTimeWindowSqlShapeTests.cs","Darling/Darling.Tests/ViewerBlockingDepthTests.cs","Darling/Darling.Tests/ViewerCalendarRetentionPortTests.cs","Darling/Darling.Tests/ViewerDailyHealthTests.cs","Darling/Darling.Tests/ViewerDeadlockSeverityStatsTests.cs","Darling/Darling.Tests/ViewerFileIoBlockingTests.cs","Darling/Darling.Tests/ViewerWave2Tests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingSlicers.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Lite.Tests/EventTimeWindowReadsTests.cs","Lite.Tests/PerformanceCalendarDataTests.cs","Lite/Services/LiteAlertReadAdapter.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.BlockingStats.cs","Lite/Services/LocalDataService.DailySummary.cs"],"failed_classes":["Darling.Tests.PgLogBurstAndRotationLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"2c797f05fefa1a5d83d86339bd0e0119c9b1af94","event":"pull_request","changed_files":["Lite.Tests/StoredEventCopiesReaderTests.cs","Lite.Tests/StoredEventCopiesSweepTests.cs","Lite.Tests/StoredEventCopiesTests.cs","Lite.Tests/WatermarkReadFailureLogTests.cs","Lite/Analysis/AnomalyDetector.cs","Lite/Analysis/BaselineProvider.cs","Lite/Analysis/DrillDownCollector.Blocking.cs","Lite/Analysis/DuckDbFactCollector.Waits.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/StoredEventCopies.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.BlockingStats.cs","Lite/Services/LocalDataService.DailySummary.cs","Lite/Services/LocalDataService.LongQueries.cs","Lite/Services/LocalDataService.Overview.cs","Lite/Services/LocalDataService.SystemEvents.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs","PerformanceMonitor.Collectors/SystemHealthEventsCollector.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests","Darling.Tests.ConsumedTimestampFrameDisciplineTests","Darling.Tests.DarlingEventBaselineCoveredDaysTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-10-01","head_sha":"2a0d4bd49c0519c2aa610bed15aa3fd367a2c73f","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/DarlingEventBaselineCoveredDaysTests.cs","Lite.Tests/EventBaselineCoveredDaysTests.cs","Lite.Tests/StoredEventCopiesReaderTests.cs","Lite.Tests/StoredEventCopiesSweepTests.cs","Lite.Tests/StoredEventCopiesTests.cs","Lite.Tests/WatermarkReadFailureLogTests.cs","Lite/Analysis/AnomalyDetector.cs","Lite/Analysis/BaselineProvider.cs","Lite/Analysis/DrillDownCollector.Blocking.cs","Lite/Analysis/DuckDbFactCollector.Waits.cs","Lite/Database/DuckDbInitializer.cs","Lite/Database/StoredEventCopies.cs","Lite/Services/LocalDataService.Blocking.cs","Lite/Services/LocalDataService.BlockingStats.cs","Lite/Services/LocalDataService.DailySummary.cs","Lite/Services/LocalDataService.LongQueries.cs","Lite/Services/LocalDataService.Overview.cs","Lite/Services/LocalDataService.SystemEvents.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs","PerformanceMonitor.Collectors/SystemHealthEventsCollector.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-01","head_sha":"e20c88136040d5c7d1ee48bff2f88a4bf9cfd454","event":"pull_request","changed_files":["CHANGELOG.md","docs/changelog/3.9.md","tools/changelog/archive-census.txt"],"failed_classes":["Darling.Tests.MigrationUpgradeLadderLiveTests"],"failed_jobs":["build"]} +{"date":"2026-10-01","head_sha":"8b5919e8fd383a048709c86991c9630da78e3e83","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureYoungBaselineWaitBarTests.cs","Darling/Darling.Tests/WaitProfileLedByTests.cs","Lite.Tests/AnomalyDetectorTests.cs"],"failed_classes":["Darling.Tests.AzureYoungBaselineWaitBarTests","Darling.Tests.WaitProfileLedByTests","PerformanceMonitorLite.Tests.AnomalyDetectorTests"],"failed_jobs":["Lite tests (2)","build","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"7df4f3da8b4c2efea4ecafc87fbf1b3896d1a08d","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertHistoryDisplayNameLiveTests.cs","Darling/Darling.Tests/DarlingMcpAlertToolsTests.cs","Darling/Darling.Tests/HoldEarnedByDeliveryTests.cs","Darling/Darling.Tests/ViewerW2aTests.cs","Darling/Darling.Tests/ViewerWave3Tests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingAlertReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AlertHistory.cs","Lite.Tests/AlertHistoryDisplayNameTests.cs","Lite/Controls/AlertsHistoryTab.xaml.cs","Lite/MainWindow.xaml.cs","Lite/Services/LocalDataService.AlertHistory.cs"],"failed_classes":["Darling.Tests.AlertNotebookResolutionReadLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-01","head_sha":"e19ece986ba502757d7370f764a0c365c7f178a8","event":"pull_request","changed_files":["Darling/Darling.Tests/DailySummaryAzureMasterScopeLiveTests.cs","Darling/Darling.Tests/DailySummaryAzureMasterScopeTests.cs","Darling/Darling.Tests/DarlingMcpFleetSweepToolsTests.cs","Darling/Darling.Tests/FleetOverviewAzureMasterScopeLiveTests.cs","Darling/Darling.Tests/McpServiceParameterDiSeatCensusTests.cs","Darling/Darling.Tests/SeparatelyMonitoredListNoteTests.cs","Darling/Darling.Tests/ViewerDailySummaryAzureMasterScopeLiveTests.cs","Darling/Darling.Tests/ViewerFleetAzureMasterScopeLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/FleetSweepEngine.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingHealthReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFleetTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs","Darling/PerformanceMonitor.Darling.Storage/DailySummaryRangeCache.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.Locking.cs","Darling/PerformanceMonitor.Darling.Viewer/FinOpsTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.AzureMasterScope.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.DailySummary.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Fleet.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Overview.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","PerformanceMonitor.Alerting/AzureMasterScope.cs"],"failed_classes":["Darling.Tests.ServerPageTabsTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-10-02","head_sha":"af31e98db0c6c7af4953d36a403403db0d4502e5","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeDataFloorLiveTests.cs","Darling/Darling.Tests/ComposeRollupDataFloorLiveTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/DataWindowFloorTests.cs","Darling/Darling.Tests/LiveStoreSessionTimeZone.cs","Darling/Darling.Tests/LiveStoreSessionTimeZoneTests.cs","Darling/Darling.Tests/QueryStoreTopWindowTests.cs","Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs","Darling/Darling.Tests/ViewerDataStartBannerTests.cs","Darling/Darling.Tests/web-compose-notice-harness.mjs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs","Darling/PerformanceMonitor.Darling.Storage/DataWindowFloor.cs","Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml"],"failed_classes":["Darling.Tests.StoreSessionTimeZoneLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-02","head_sha":"0065d49b740238b1a24d256f65a307515deaeb48","event":"push","changed_files":[".github/FUNDING.yml"],"failed_classes":["Darling.Tests.PlanSync4512CancellationTests"],"failed_jobs":["Darling whole-tree guards"]} +{"date":"2026-10-02","head_sha":"6de10f4dcb1f6d48b0c6ca04986b785e2d295c00","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeDataFloorLiveTests.cs","Darling/Darling.Tests/ComposeRollupDataFloorLiveTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/DataWindowFloorTests.cs","Darling/Darling.Tests/LiveStoreSessionTimeZone.cs","Darling/Darling.Tests/LiveStoreSessionTimeZoneTests.cs","Darling/Darling.Tests/QueryStoreTopWindowTests.cs","Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs","Darling/Darling.Tests/ViewerDataStartBannerTests.cs","Darling/Darling.Tests/web-compose-notice-harness.mjs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs","Darling/PerformanceMonitor.Darling.Storage/DataWindowFloor.cs","Darling/PerformanceMonitor.Darling.Storage/RawWindowFloor.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QuerySnapshots.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.ActiveQueries.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Lite.Tests/DataStartBannerTests.cs","Lite.Tests/QueryWindowTruncationTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.Slicers.cs","Lite/Controls/ServerTab.xaml","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs"],"failed_classes":["Darling.Tests.StoreSessionTimeZoneLiveTests","PerformanceMonitorLite.Tests.DisplayZoneTextFrameTests"],"failed_jobs":["Lite tests (3)","Darling PG tests (1)"]} +{"date":"2026-10-02","head_sha":"a606e43521c458e3f9ce77109ae2877c7139801c","event":"pull_request","changed_files":["Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs","Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs","Darling/Darling.Tests/DropXeSessionsPerInstallTests.cs","Darling/Darling.Tests/DropXeSessionsVerbTests.cs","Darling/Darling.Tests/EmptyDatabaseListNoteLiveTests.cs","Darling/Darling.Tests/EmptyDatabaseListNoteTests.cs","Darling/Darling.Tests/InstallIdRungTests.cs","Darling/Darling.Tests/LegacyLongQueryRecordLiveTests.cs","Darling/Darling.Tests/LongQueryBannerSessionScopeTests.cs","Darling/Darling.Tests/LongQueryTraceInstanceGuardTests.cs","Darling/Darling.Tests/LongQueryTraceLifecycleTests.cs","Darling/Darling.Tests/LongQueryTraceReadOnlyIntentTests.cs","Darling/Darling.Tests/LongQueryTraceRestartTests.cs","Darling/Darling.Tests/MuteRuleServerIdRungTests.cs","Darling/Darling.Tests/PostgresEngineGateBehaviorTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/StoreInstallIdLiveTests.cs","Darling/Darling.Tests/StoreInstallIdRefusedClusterLiveTests.cs","Darling/Darling.Tests/StoreInstallIdSourcePinTests.cs","Darling/Darling.Tests/XeSessionMissingWarningTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlwaysOnXeSessions.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingLegacyLongQuerySession.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/DarlingXeSessionCleanup.cs","Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs","Darling/PerformanceMonitor.Darling.Service/XeSessionMissingWarnings.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreInstallId.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml","Darling/README.md","Lite.Tests/AlwaysOnXeSessionsLiteTests.cs","Lite.Tests/DeadlockAndBlockedProcessEnsureLogLevelLiteTests.cs","Lite.Tests/EmptyDatabaseListNoteLiteTests.cs","Lite.Tests/InstallIdStoreTests.cs","Lite.Tests/InstallIdTests.cs","Lite.Tests/LongQueryBannerSessionScopeTests.cs","Lite.Tests/LongQueryCompletionsCollectorDefinitionTests.cs","Lite.Tests/LongQueryTraceLifecycleLiteTests.cs","Lite.Tests/LongQueryTraceReadOnlyIntentLiteTests.cs","Lite.Tests/SettingsImportTests.cs","Lite.Tests/SwallowedItemFailureTests.cs","Lite/Controls/ServerTab.xaml","Lite/MainWindow.xaml.cs","Lite/Mcp/McpLongQueryTools.cs","Lite/Services/InstallIdStore.cs","Lite/Services/RemoteCollectorService.AlwaysOnXeSessions.cs","Lite/Services/RemoteCollectorService.BlockedProcessReport.cs","Lite/Services/RemoteCollectorService.Deadlocks.cs","Lite/Services/RemoteCollectorService.DefinitionRunner.cs","Lite/Services/RemoteCollectorService.LegacyLongQuerySession.cs","Lite/Services/RemoteCollectorService.LongQueryCompletions.cs","Lite/Services/RemoteCollectorService.cs","Lite/Services/ScheduleManager.cs","Lite/Services/SettingsImport.cs","PerformanceMonitor.Collectors/AlwaysOnXeSessions.cs","PerformanceMonitor.Collectors/BlockedProcessReportCollector.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs","PerformanceMonitor.Collectors/DeadlocksCollector.cs","PerformanceMonitor.Collectors/EmptyDatabaseListNote.cs","PerformanceMonitor.Collectors/ICollectorDefinition.cs","PerformanceMonitor.Collectors/InstallId.cs","PerformanceMonitor.Collectors/LegacyLongQuerySession.cs","PerformanceMonitor.Collectors/LongQueryCompletionsCollector.cs","PerformanceMonitor.Collectors/LongQueryTraceDatabases.cs","PerformanceMonitor.Collectors/LongQueryTraceStep.cs","PerformanceMonitor.Collectors/ServerEpoch.cs"],"failed_classes":["Darling.Tests.CollectorStateContractTests","Darling.Tests.DarlingCollectorToggleVerbTests","Lite.Tests.XeTimeFilterSweepTests","PerformanceMonitorLite.Tests.EntraDeviceCodeTests","PerformanceMonitorLite.Tests.StoredEventCopiesTests"],"failed_jobs":["Lite tests (0)","Lite tests (1)","Darling PG tests (2)","build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-10-02","head_sha":"ce33caec1b33983f52a1a181d9da4d1d42375a8d","event":"pull_request","changed_files":["Darling/Darling.Tests/FleetGateSignalTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/SelfAlertDeliveryStampTests.cs","Darling/Darling.Tests/StoreMaintenanceTickLaunchTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"],"failed_classes":["Darling.Tests.PgLogEventsCsvlogLiveTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-10-02","head_sha":"a9b554fa704f0855fde9bb44da5ad01fadcb8089","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs","Darling/Darling.Tests/QueryStoreBackgroundIndexesTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBrinIndexTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideFirstExecFloorTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreBackgroundIndexes.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWideBrinIndex.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-10-03","head_sha":"b9411680b270adff87459ed8b6dcc728d1a25b56","event":"pull_request","changed_files":["Darling/Darling.Tests/BoundedCompressionDdlTests.cs","Darling/Darling.Tests/FleetGateSignalTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/SelfAlertDeliveryStampTests.cs","Darling/Darling.Tests/StoreMaintenanceTickLaunchTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/Darling.Tests/TuningHourlyCreateLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"de3cabd7eb86bb3e50d839d76a7f093b2c2bb8d4","event":"pull_request","changed_files":["Darling/Darling.Tests/BoundedCompressionDdlTests.cs","Darling/Darling.Tests/BoundedDdlBusyStreakTests.cs","Darling/Darling.Tests/BoundedDdlSingleCopyTests.cs","Darling/Darling.Tests/FleetGateSignalTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/RetentionReevaluationTests.cs","Darling/Darling.Tests/SelfAlertDeliveryStampTests.cs","Darling/Darling.Tests/StoreMaintenanceTickLaunchTests.cs","Darling/Darling.Tests/StoreObjectConvergenceTests.cs","Darling/Darling.Tests/StoreObjectHourlyStepSelectionTests.cs","Darling/Darling.Tests/StoreToastAndCheckpointerTests.cs","Darling/Darling.Tests/TimescaleAvailabilityReprobeTests.cs","Darling/Darling.Tests/TimescaleSupportTests.cs","Darling/Darling.Tests/TuningHourlyCreateLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/BoundedDdlBusyStreaks.cs","Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.CollectionLogSegmentByLiveTests","Darling.Tests.CompressionEnableGuardTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-10-03","head_sha":"bc4bdca4a7f784517222385e33adce0381d17daa","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionHealthKeyedLookupParityTests.cs","Darling/Darling.Tests/CollectionHealthOracleSql.cs","Darling/Darling.Tests/CollectorHealthClassifierTests.cs","Darling/Darling.Tests/DarlingEmptyEnumerationNoteTests.cs","Darling/Darling.Tests/ProductiveZeroBandingTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.LivePlanShapeIsolationCensusTests","Darling.Tests.ViewerCollectionHealthSqlTests"],"failed_jobs":["Darling PG tests (2)","Darling PG tests (0)","build"]} +{"date":"2026-10-03","head_sha":"fb8e0ad86888b2f694ff6b9d61d451280d59b02a","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreRawDedupeFilterLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.QueryStoreSliceTieBreakSourceTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-10-03","head_sha":"15173c8b89cf75b38c1353c44c89f950d12c1b43","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreRawDedupeFilterLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs"],"failed_classes":["Darling.Tests.QueryStoreSliceTieBreakSourceTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"2f652475b8a0f89de896dcce424e2f5fe4a83911","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreRawDedupeFilterLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs"],"failed_classes":["Darling.Tests.QueryStoreSliceTieBreakSourceTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"b47aad7fc80dd96ddeee3750ecbc73934832cd1c","event":"pull_request","changed_files":["Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/DataStartBannerTests.cs","Lite/Controls/ServerTab.ConfigChanges.cs","Lite/Controls/ServerTab.LongQueries.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.SystemEvents.cs","Lite/Controls/ServerTab.xaml","Lite/Services/LocalDataService.QueryWindowFloor.cs"],"failed_classes":["PerformanceMonitorLite.Tests.StoredEventCopiesSweepTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-10-03","head_sha":"2aff8820c433392d15303e52a8bb6b218087fdd9","event":"pull_request","changed_files":["Lite.Tests/DataStartBannerBlockingEventsTests.cs","Lite.Tests/QueryWindowTruncationTests.cs","Lite/Controls/ServerTab.DrillDown.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.Slicers.cs","Lite/Controls/ServerTab.xaml","Lite/Services/LocalDataService.QueryWindowFloor.cs"],"failed_classes":["PerformanceMonitorLite.Tests.StoredEventCopiesSweepTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-10-03","head_sha":"ad4b71823a3cc4337e64b0900213ece9106b8af2","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/DropXeSessionsVerbTests.cs","Darling/Darling.Tests/InstallIdRungTests.cs","Darling/Darling.Tests/InstallIdTableOidRungTests.cs","Darling/Darling.Tests/StoreInstallIdBindingRuleTests.cs","Darling/Darling.Tests/StoreInstallIdLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingXeSessionCleanup.cs","Darling/PerformanceMonitor.Darling.Service/DarlingXeSessions.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreInstallId.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md","Lite.Tests/AlwaysOnXeSessionsLiteTests.cs","Lite.Tests/InstallIdStoreTests.cs","Lite.Tests/LongQueryTraceLifecycleLiteTests.cs","Lite/Services/InstallIdStore.cs","Lite/Services/RemoteCollectorService.AlwaysOnXeSessions.cs","Lite/Services/RemoteCollectorService.LongQueryCompletions.cs","Lite/Services/RemoteCollectorService.cs","PerformanceMonitor.Collectors/AlwaysOnXeSessions.cs","README.md"],"failed_classes":["PerformanceMonitor.Darling.Tests.RetiredBaselineAggregateLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-03","head_sha":"964bf56926c63b31c3c73f3b81cb925ae9a152de","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreWideFleetEligibilityLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreIntervalWide.cs"],"failed_classes":["Darling.Tests.FleetIdentifierScrubTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"761b9398ae180a473c2dacd6abb70e610fc62842","event":"pull_request","changed_files":["Lite.Tests/DataStartBannerQueriesTabTests.cs","Lite.Tests/DataStartBannerTests.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.QueriesDataStart.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.xaml","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["build"]} +{"date":"2026-10-03","head_sha":"10f7e41347b062455016cb85d356e8a43ec20a00","event":"pull_request","changed_files":["Darling/Darling.Tests/CommentFilterAdoptionTests.cs","Lite.Tests/DataStartBannerQueriesTabTests.cs","Lite.Tests/DataStartBannerTests.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.QueriesDataStart.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.xaml","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs"],"failed_classes":["Lite.Tests.CrossAppGuardCiGateTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-10-03","head_sha":"8f68d2dd8295393b26f76b3baa822b5f30c86ec8","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorFaultStackLogTests.cs","Darling/Darling.Tests/RdsLogStateConcurrencyTests.cs","Darling/PerformanceMonitor.Darling.Service/CollectorFaultStackLog.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsCsvlogCarry.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsDeadlockCarry.cs","Darling/PerformanceMonitor.Darling.Service/Targets/RdsLogSource.cs"],"failed_classes":["Darling.Tests.PostgresEngineGateBehaviorTests"],"failed_jobs":["Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"3bb3ae8fe975ee0be69258c3ba40223c044896aa","event":"pull_request","changed_files":["Darling/Darling.Tests/DataWindowFloorTests.cs","Darling/Darling.Tests/ViewerDataStartBannerTests.cs","Darling/Darling.Tests/ViewerEventDataStartLiveTests.cs","Darling/Darling.Tests/ViewerEventDataStartTests.cs","Darling/Darling.Tests/ViewerEventGridsDataStartLiveTests.cs","Darling/Darling.Tests/ViewerLongQueriesDataStartTests.cs","Darling/Darling.Tests/ViewerPlanCorrectionsDataStartTests.cs","Darling/Darling.Tests/ViewerQueryGridsDataStartLiveTests.cs","Darling/Darling.Tests/ViewerQueryHeatmapDataStartTests.cs","Darling/Darling.Tests/ViewerQueryStoreRegressionsDataStartTests.cs","Darling/Darling.Tests/ViewerShortWindowDataStartTests.cs","Darling/Darling.Tests/ViewerSystemEventsDataStartTests.cs","Darling/PerformanceMonitor.Darling.Storage/DataWindowFloor.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Deadlock.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.LongQueries.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PlanCorrection.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryHeatmap.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreRegressions.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerEventDataStart.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.EventDataStart.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.LongQueries.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Queries.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.QueryHeatmap.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.SystemEvents.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml"],"failed_classes":["Darling.Tests.DailySummaryNotCarriedLiveTests","Darling.Tests.StoreMetricsLatestSkipScanLiveTests","Darling.Tests.TrendPayloadBudgetLiveTests","Darling.Tests.ViewerCpuTempDbLivePostgresTests","Darling.Tests.WebExceptionTextCensusTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-03","head_sha":"a55a242505154020357d9618af7e150320eab9e0","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CollectorRunTimeRungTests.cs","Darling/Darling.Tests/CollectorRunTimeTests.cs","Darling/Darling.Tests/DarlingSweepSchedulingTests.cs","Darling/Darling.Tests/InstallIdTableOidRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","PerformanceMonitor.Collectors/CollectorCadence.cs","PerformanceMonitor.Collectors/CollectorRunTime.cs","PerformanceMonitor.Collectors/CollectorScheduleDefaults.cs"],"failed_classes":["Darling.Tests.StartupCommandTimeoutTests"],"failed_jobs":["Darling PG tests (0)","build"]} +{"date":"2026-10-03","head_sha":"261356ae825662fb7dd004f30655b54eee72cb29","event":"pull_request","changed_files":["Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/DataStartBannerTests.cs","Lite.Tests/QueryWindowTruncationTests.cs","Lite.Tests/StoredEventCopiesSweepTests.cs","Lite/Controls/ServerTab.ConfigChanges.cs","Lite/Controls/ServerTab.LongQueries.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.SystemEvents.cs","Lite/Controls/ServerTab.xaml","Lite/Services/LocalDataService.CollectionHealth.cs","Lite/Services/LocalDataService.LongQueries.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs"],"failed_classes":["Darling.Tests.McpPayloadContractCensusTests"],"failed_jobs":["build"]} +{"date":"2026-10-03","head_sha":"46cc97a10aca73111a9a277cf3637b0ce12445e4","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpFinOpsToolsTests.cs","Darling/Darling.Tests/FinOpsHighImpactReaderLiveTests.cs","Darling/Darling.Tests/FinOpsWebReadParityLiveTests.cs","Darling/Darling.Tests/McpToolGuideHeads.FinOps.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpFinOpsTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/ViewerFinOpsHighImpactDelegatesTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.HighImpact.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsHighImpactReader.cs","Darling/PerformanceMonitor.Darling.Storage/FinOps/FinOpsScoring.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs"],"failed_classes":["Darling.Tests.CollectorEngineCapabilityTests","Darling.Tests.EngineCapabilityReadWiringTests","Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (1)"]} +{"date":"2026-10-03","head_sha":"8d786686324f6cf74484722c172f5a10d3aee512","event":"pull_request","changed_files":["Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Lite.Tests/DataStartBannerProbeFailureTests.cs","Lite.Tests/DataStartBannerQueriesTabTests.cs","Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/DataStartBannerTests.cs","Lite.Tests/DisplayZoneTextFrameTests.cs","Lite.Tests/QueryWindowTruncationTests.cs","Lite.Tests/StoredEventCopiesSweepTests.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Controls/ServerTab.ConfigChanges.cs","Lite/Controls/ServerTab.LongQueries.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Controls/ServerTab.SystemEvents.cs","Lite/Controls/ServerTab.xaml","Lite/Services/LocalDataService.CollectionHealth.cs","Lite/Services/LocalDataService.LongQueries.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","PerformanceMonitor.Ui/ChartHoverHelper.cs"],"failed_classes":["PerformanceMonitor.Darling.Tests.RetiredBaselineAggregateLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-03","head_sha":"f6717e306185c1653560e54afe916463320b6637","event":"pull_request","changed_files":["Darling/Darling.Tests/AsOfWindowAnchorTests.cs","Darling/Darling.Tests/DarlingMcpFinOpsToolsTests.cs","Darling/Darling.Tests/FinOpsHighImpactReaderLiveTests.cs","Darling/Darling.Tests/FinOpsWebReadParityLiveTests.cs","Darling/Darling.Tests/McpToolGuideHeads.FinOps.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpFinOpsTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/StorageCommandTimeoutTests.cs","Darling/Darling.Tests/ViewerFinOpsHighImpactDelegatesTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.HighImpact.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsHighImpactReader.cs","Darling/PerformanceMonitor.Darling.Storage/FinOps/FinOpsScoring.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.Workload.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.FinOps.cs","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs"],"failed_classes":["Darling.Tests.CollectorEngineCapabilityTests","Darling.Tests.EngineCapabilityReadWiringTests","Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["build","Darling PG tests (1)","Lite tests (1)"]} +{"date":"2026-10-03","head_sha":"3bde72bf30bf5b9f285d4aba415851e2d84d2544","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpFinOpsToolsTests.cs","Darling/Darling.Tests/EngineCapabilityMissTests.cs","Darling/Darling.Tests/FinOpsUtilizationGoldenLiveTests.cs","Darling/Darling.Tests/FinOpsUtilizationViewLiveTests.cs","Darling/Darling.Tests/FinOpsWebReadParityLiveTests.cs","Darling/Darling.Tests/McpToolGuideHeads.FinOps.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpFinOpsTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.Utilization.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpFinOpsTools.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"a9f62df282a013db88adf22004f5077c83b5af4f","event":"pull_request","changed_files":["Darling/Darling.Tests/JobHistoryCountTextTests.cs","Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs","Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs","Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs","Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs","Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/JobHistoryDataStartTests.cs","Lite.Tests/JobHistoryServerClockWindowTests.cs","Lite/Controls/JobHistoryTab.xaml","Lite/Controls/JobHistoryTab.xaml.cs","Lite/MainWindow.xaml.cs","Lite/Services/LocalDataService.JobHistory.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","PerformanceMonitor.Common/JobHistoryCap.cs"],"failed_classes":["Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"99830570d14e086815f23a2fd17efb81215fafcf","event":"pull_request","changed_files":["Darling/Darling.Tests/ViewerBlockingChartsDataStartLiveTests.cs","Darling/Darling.Tests/ViewerBlockingChartsDataStartPinTests.cs","Darling/Darling.Tests/ViewerBlockingChartsDataStartTests.cs","Darling/Darling.Tests/ViewerEventDataStartTests.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerBlockingChartsDataStart.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.xaml"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-03","head_sha":"ffed3540553268d36da0b864d72917c91ed0e382","event":"pull_request","changed_files":["Darling/Darling.Tests/JobHistoryCountTextTests.cs","Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs","Darling/Darling.Tests/ViewerJobHistoryDataStartLiveTests.cs","Darling/Darling.Tests/ViewerJobHistoryDataStartTests.cs","Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml","Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs","Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/JobHistoryDataStartTests.cs","Lite.Tests/JobHistoryServerClockWindowTests.cs","Lite/Controls/JobHistoryTab.xaml","Lite/Controls/JobHistoryTab.xaml.cs","Lite/MainWindow.xaml.cs","Lite/Services/LocalDataService.JobHistory.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","PerformanceMonitor.Common/JobHistoryCap.cs"],"failed_classes":["Darling.Tests.ViewerCommandTimeoutTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-04","head_sha":"c0866d29a2658fa00eaa17e159cf86bf086289a5","event":"push","changed_files":["Darling/Darling.Tests/FinOpsIndexAnalysisLatestTiebreakTests.cs","Darling/Darling.Tests/Fixtures/FinOpsIndexAnalysis/golden.json","Darling/PerformanceMonitor.Darling.Storage/FinOps/DarlingFinOpsIndexAnalysisReader.cs"],"failed_classes":["Darling.Tests.QueryStoreLegacyRowIndexLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-04","head_sha":"475665904568c142e4bbddf9825b9d8d3c36b41e","event":"pull_request","changed_files":["Darling/Darling.Tests/FinOpsTabOptimizationPageTests.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/optimization.js"],"failed_classes":["Darling.Tests.QueryStoreLegacyRowIndexLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-04","head_sha":"9f612f13de4ef902619e7b7bdb6ad94b21c56b22","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeParameterCoverageTests.cs","Darling/Darling.Tests/ComposeSourceRouterTests.cs","Darling/Darling.Tests/DarlingComposeTests.cs","Darling/Darling.Tests/IntervalRollupCountGuardLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs","Darling/PerformanceMonitor.Darling.Service/Compose/ComposeStoreAvailability.cs","Darling/PerformanceMonitor.Darling.Storage/IntervalRollupCountGuard.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-04","head_sha":"c38fa98b46741b3b067206fd4301fe5628184b4d","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingMcpServerTagToolsTests.cs","Darling/Darling.Tests/DarlingServerTagsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpReadToolBudgetLiveTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpServerTagTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/ServerTagCoverageTests.cs","Darling/Darling.Tests/ServerTagDeleteWarningTests.cs","Darling/Darling.Tests/ServerTagMcpRoleLiveTests.cs","Darling/Darling.Tests/ServerTagRulesTests.cs","Darling/Darling.Tests/ServerTagStoreLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerTagTools.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/ServerTagCoverage.cs","Darling/PerformanceMonitor.Darling.Storage/ServerTagRules.cs","Darling/PerformanceMonitor.Darling.Storage/ServerTagStore.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.Tags.cs","Darling/PerformanceMonitor.Darling.Viewer/ManageTagsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerTags.cs","Darling/tools/provision-roles.sql","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","PerformanceMonitor.Common/TagColours.cs","PerformanceMonitor.Ui/TagColorPalette.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.DarlingMcpInstructionsBudgetTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)","build"]} +{"date":"2026-10-04","head_sha":"80c56c3d552d98efbdcc91ac7007e2eed248ce76","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpJobHistoryToolTests.cs","Darling/Darling.Tests/DarlingMcpJobToolsTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpJobTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/packages.lock.json","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingJobHistoryReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingServerClocksReader.cs","Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json","README.md","llms.txt"],"failed_classes":["Lite.Tests.CrossAppMcpToolInventoryPinTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-10-04","head_sha":"e4e3e8a4076105015706db8446ac55a16ddcdbd6","event":"pull_request","changed_files":["Darling/Darling.Tests/ActiveQueriesWidenedColumnsTests.cs","Darling/Darling.Tests/DarlingMcpActiveQueriesBudgetLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSessionReader.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js"],"failed_classes":["Darling.Tests.FinOpsWindowPickerBehaviourTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (2)","build"]} +{"date":"2026-10-04","head_sha":"ad44369b11a8a4018c7349da95973e65c36accc3","event":"pull_request","changed_files":["Darling/Darling.Tests/DatabaseConfigWebColumnsTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js"],"failed_classes":["Darling.Tests.FinOpsWindowPickerBehaviourTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (2)"]} +{"date":"2026-10-04","head_sha":"5a143e3f860ccb669e759a7dc99056330a8b60dd","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertHistoryRangePageTests.cs","Darling/Darling.Tests/alert-history-range-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/theme.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js"],"failed_classes":["Darling.Tests.FinOpsWindowPickerBehaviourTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (2)"]} +{"date":"2026-10-04","head_sha":"d8246671f1bc9913be7bfec5bc30317921d03335","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpJobHistoryToolTests.cs","Darling/Darling.Tests/DarlingMcpJobToolsTests.cs","Darling/Darling.Tests/JobHistorySqlTextPinTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpJobTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/packages.lock.json","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpJobTools.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingJobHistoryReader.cs","Darling/PerformanceMonitor.Darling.Storage/DarlingServerClocksReader.cs","Darling/PerformanceMonitor.Darling.Storage/PerformanceMonitor.Darling.Storage.csproj","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.JobHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/packages.lock.json","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.FinOpsWindowPickerBehaviourTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)"]} +{"date":"2026-10-04","head_sha":"c20797c24a525d965b181a3f8d8ae47a236c97aa","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingWebAssetsTests.cs","Darling/Darling.Tests/GridToolsBehaviourTests.cs","Darling/Darling.Tests/finops-window-picker-harness.mjs","Darling/Darling.Tests/web-grid-tools-harness.mjs","Darling/Darling.Tests/web-kept-history-harness.mjs","Darling/Darling.Tests/web-perfmon-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/theme.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/grid-tools.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js"],"failed_classes":["Darling.Tests.PgDeadlockNormalizationTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-10-04","head_sha":"d40edb18e786a2f1960c5377d6540eba170c3053","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionCaveatsWebTests.cs","Darling/Darling.Tests/web-collection-caveats-harness.mjs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCollectionCaveatReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js"],"failed_classes":["Darling.Tests.PgIndexBloatGridReachTests","Darling.Tests.WebMemoryPressureChartBehaviourTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (1)"]} +{"date":"2026-10-04","head_sha":"863017a89e7e8d1e15d1c7529fe535158e005dc7","event":"pull_request","changed_files":["Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/CollectorRunTimeRungTests.cs","Darling/Darling.Tests/QueryStoreTopDailyBuilderLiveTests.cs","Darling/Darling.Tests/QueryStoreTopDailyRungTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/QueryStoreTopDaily.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.QueryStoreTopDailyRungTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (2)"]} +{"date":"2026-10-04","head_sha":"7abe9e9507ecafb7d8c3b38d73b751016401a9bf","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingMcpServerTagToolsTests.cs","Darling/Darling.Tests/DarlingServerTagsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpReadToolBudgetLiveTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpServerTagTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/ServerRemovalClearsTagsLiveTests.cs","Darling/Darling.Tests/ServerTagCoverageTests.cs","Darling/Darling.Tests/ServerTagDeleteWarningTests.cs","Darling/Darling.Tests/ServerTagMcpRoleLiveTests.cs","Darling/Darling.Tests/ServerTagRulesTests.cs","Darling/Darling.Tests/ServerTagStoreLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/CustomAlertRuleStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerTagTools.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/ServerTagCoverage.cs","Darling/PerformanceMonitor.Darling.Storage/ServerTagRules.cs","Darling/PerformanceMonitor.Darling.Storage/ServerTagStore.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.Tags.cs","Darling/PerformanceMonitor.Darling.Viewer/ManageTagsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerTags.cs","Darling/tools/provision-roles.sql","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","PerformanceMonitor.Common/TagColours.cs","PerformanceMonitor.Ui/TagColorPalette.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.LivePlanShapeIsolationCensusTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-04","head_sha":"bf388bec5970751d91fcd835723b546464c50241","event":"pull_request","changed_files":["Darling/Darling.Tests/WebWaitStatsMultiSelectBehaviourTests.cs","Darling/Darling.Tests/web-kept-history-harness.mjs","Darling/Darling.Tests/web-memory-pressure-harness.mjs","Darling/Darling.Tests/web-perfmon-harness.mjs","Darling/Darling.Tests/web-waits-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/multi-picker.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.QueryStoreTopDailyRungTests","PerformanceMonitorLite.Tests.BaselineDiscontinuityRenderCensusTests"],"failed_jobs":["Darling PG tests (0)","Darling PG tests (2)","Lite tests (1)","build"]} +{"date":"2026-10-04","head_sha":"3e1f8d1ef99ecc64540d515182acd371294c46ce","event":"pull_request","changed_files":["Darling/Darling.Tests/WebPageRefreshIntervalBehaviourTests.cs","Darling/Darling.Tests/web-page-refresh-harness.mjs","Darling/Darling.Tests/web-scheduler-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/refresh-control.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/refresh-policy.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests"],"failed_jobs":["Darling PG tests (2)","build","Darling whole-tree guards"]} +{"date":"2026-10-05","head_sha":"87e78aa474eb4b31f2d5a4687ec5ddcb9224cb6b","event":"pull_request","changed_files":["Darling/Darling.Tests/WebPerfmonMultiSelectBehaviourTests.cs","Darling/Darling.Tests/WebPerfmonPerSecondBehaviourTests.cs","Darling/Darling.Tests/web-perfmon-multi-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/multi-picker.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Lite.Tests/BaselineDiscontinuityRenderCensusTests.cs"],"failed_classes":["Darling.Tests.ViewerEventDataStartTests","PerformanceMonitorLite.Tests.DataStartBannerProbeFailureTests"],"failed_jobs":["build","Lite tests (0)","Darling PG tests (1)"]} +{"date":"2026-10-05","head_sha":"68f25798ce23de98af66a942062faa1f3b529e39","event":"pull_request","changed_files":["Darling/Darling.Tests/ConsumedTimestampFrameDisciplineTests.cs","Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDataTools.txt","Darling/Darling.Tests/TopQueriesDetailLiveTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingLiveTests.cs","Darling/Darling.Tests/WebServerPageRangeTests.cs","Darling/Darling.Tests/WebTopQueriesColumnsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js"],"failed_classes":["Darling.Tests.SeparatelyMonitoredListNoteTests"],"failed_jobs":["Darling PG tests (1)","build"]} +{"date":"2026-10-05","head_sha":"5fe6eddad2fda2e493b00813b349abe8f0832c49","event":"pull_request","changed_files":["Darling/Darling.Tests/PayloadDigestPassThroughLiveTests.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionBatch.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionWriter.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensions.cs","Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs"],"failed_classes":["Darling.Tests.PayloadDigestPassThroughLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-05","head_sha":"183d90d1687521c78299aee6146f1b85d1962e7c","event":"pull_request","changed_files":["Darling/Darling.Tests/ProcedureStatsPlanFetchTests.cs","Darling/Darling.Tests/ProcedureStatsSqlGoldenTests.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/ProcedureStatsCollector.cs"],"failed_classes":["Darling.Tests.DarlingMcpServerAdminToolsSurfaceTests","Darling.Tests.PayloadDigestPassThroughLiveTests","Darling.Tests.ServerAddViewerRoleLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-05","head_sha":"5b70a510603dce2afddd475de67d2aa9ac6d11f6","event":"pull_request","changed_files":["Darling/Darling.Tests/PayloadDigestPassThroughLiveTests.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionBatch.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionWriter.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensions.cs","Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs"],"failed_classes":["Darling.Tests.PayloadDigestPassThroughLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-05","head_sha":"956e5deff20766d7de24eca089e94f79c4bf64ab","event":"pull_request","changed_files":["Darling/Darling.Tests/ProcedureStatsPlanFetchTests.cs","Darling/Darling.Tests/ProcedureStatsSqlGoldenTests.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/ProcedureStatsCollector.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-05","head_sha":"1acb3d71040f5820312bdbe66b21cd5bc829a504","event":"push","changed_files":["Darling/Darling.Tests/ProcedureStatsPlanFetchTests.cs","Darling/Darling.Tests/ProcedureStatsSqlGoldenTests.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/ProcedureStatsCollector.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-05","head_sha":"c1063c5c89bd9deac5787722d46d649a87325ea5","event":"pull_request","changed_files":["Darling/Darling.Tests/PayloadDigestPassThroughLiveTests.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionBatch.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionWriter.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensions.cs","Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PayloadDigestPassThroughLiveTests"],"failed_jobs":["Darling PG tests (2)","build","Darling PG tests (0)"]} +{"date":"2026-10-05","head_sha":"6743f8286b89a4314c3714acf7ab3ab874c8f056","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/SlowReadsRungTests.cs","Darling/Darling.Tests/StoreSelfMetricsTests.cs","Darling/Darling.Tests/StoreStatementHistoryLiveTests.cs","Darling/Darling.Tests/StoreStatementHistoryRungTests.cs","Darling/Darling.Tests/StoreStatementHistoryTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Storage/StoreStatementHistory.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/README.md"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-05","head_sha":"eba83d547adecc19bbb9b107aea17c76aca774ab","event":"pull_request","changed_files":[".github/scripts/lite-timing-top.py",".github/workflows/build.yml",".github/workflows/nightly.yml"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests"],"failed_jobs":["build","Darling PG tests (2)","Darling PG tests (0)","Darling whole-tree guards"]} +{"date":"2026-10-05","head_sha":"99ad6aebf68c4d7cd0e1fd54f6bbea46a4303be0","event":"pull_request","changed_files":["Darling/Darling.Tests/PayloadDigestPassThroughLiveTests.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionBatch.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensionWriter.cs","Darling/PerformanceMonitor.Darling.Storage/PayloadDimensions.cs","Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PlanSync4512CancellationTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-05","head_sha":"0b92d8248f506ee2accb62cf0ad2b3a6f0422ffb","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingBlockingStatsReadTests.cs","Darling/Darling.Tests/EffectiveStartUtcTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/PgEventToolsWindowFloorLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgDeadlockTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPgLogEventTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpWindowNotice.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests","Darling.Tests.PgServerLogTailRotationLiveTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)","Darling whole-tree guards"]} +{"date":"2026-10-05","head_sha":"9de997ed48afc0d22a579dd989aaa3bb8d8a2a45","event":"pull_request","changed_files":[".github/ISSUE_TEMPLATE/bug_report.yml","Darling/Darling.Tests/BugReportTemplateDiagnosticsBundleTests.cs","Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs","Darling/Darling.Tests/DarlingCloudIdentityProbeTests.cs","Darling/Darling.Tests/DiagnosticsBundleAliasHardeningTests.cs","Darling/Darling.Tests/DiagnosticsBundleAliasTests.cs","Darling/Darling.Tests/DiagnosticsBundleExitCodeTests.cs","Darling/Darling.Tests/DiagnosticsBundleHardeningTests.cs","Darling/Darling.Tests/DiagnosticsBundleHealthUncutLiveTests.cs","Darling/Darling.Tests/DiagnosticsBundleLeakLiveTests.cs","Darling/Darling.Tests/DiagnosticsBundleRound2Tests.cs","Darling/Darling.Tests/DiagnosticsBundleSecretTests.cs","Darling/Darling.Tests/DiagnosticsBundleSeedFailureLiveTests.cs","Darling/Darling.Tests/DiagnosticsBundleServiceLogTests.cs","Darling/Darling.Tests/DiagnosticsBundleSizeTests.cs","Darling/Darling.Tests/DiagnosticsBundleVerbTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundle.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleAliaser.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleRunner.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleServiceLog.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/SecretTextGuard.cs","Darling/PerformanceMonitor.Darling.Service/SlowReadLog.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DiagnosticsBundleRound2Tests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-05","head_sha":"bbba545e345592ca21ace8325c2a7b07f38d4195","event":"pull_request","changed_files":[".github/ISSUE_TEMPLATE/bug_report.yml","Darling/Darling.Tests/BugReportTemplateDiagnosticsBundleTests.cs","Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs","Darling/Darling.Tests/DarlingCloudIdentityProbeTests.cs","Darling/Darling.Tests/DiagnosticsBundleAliasHardeningTests.cs","Darling/Darling.Tests/DiagnosticsBundleAliasTests.cs","Darling/Darling.Tests/DiagnosticsBundleExitCodeTests.cs","Darling/Darling.Tests/DiagnosticsBundleHardeningTests.cs","Darling/Darling.Tests/DiagnosticsBundleHealthUncutLiveTests.cs","Darling/Darling.Tests/DiagnosticsBundleLeakLiveTests.cs","Darling/Darling.Tests/DiagnosticsBundleRound2Tests.cs","Darling/Darling.Tests/DiagnosticsBundleSecretTests.cs","Darling/Darling.Tests/DiagnosticsBundleSeedFailureLiveTests.cs","Darling/Darling.Tests/DiagnosticsBundleServiceLogTests.cs","Darling/Darling.Tests/DiagnosticsBundleSizeTests.cs","Darling/Darling.Tests/DiagnosticsBundleVerbTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundle.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleAliaser.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleRunner.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleServiceLog.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/SecretTextGuard.cs","Darling/PerformanceMonitor.Darling.Service/SlowReadLog.cs","Darling/README.md"],"failed_classes":["Darling.Tests.DiagnosticsBundleSecretTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-05","head_sha":"eca647c2f4eac3539062020a28181d7e29becb39","event":"pull_request","changed_files":["Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs","Darling/Darling.Tests/DarlingExportViewerConfigTests.cs","Darling/Darling.Tests/FinOpsTabIndexAnalysisPageTests.cs","Darling/Darling.Tests/FinOpsTabRecommendationsPageTests.cs","Darling/Darling.Tests/FinOpsTabServerInventoryPageTests.cs","Darling/Darling.Tests/TestsNoSilentPassGuardTests.cs","Darling/Darling.Tests/WebCollectionHealthRunAtColumnTests.cs","Darling/Darling.Tests/WebDatabaseSizesNoteColumnTests.cs","Darling/Darling.Tests/WebModuleLoadTests.cs"],"failed_classes":["PerformanceMonitorLite.Tests.QueryStoreRegressionsToolTests","PerformanceMonitorLite.Tests.SignificantWaitsToolTests"],"failed_jobs":["Lite tests (3)","Lite tests (2)"]} +{"date":"2026-10-05","head_sha":"48216212d0856c5343d6aed0c877f2df261b5ddd","event":"pull_request","changed_files":["Lite.Tests/DataStartBannerQueriesTabTests.cs","Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/McpWindowNoticeAggregateToolTests.cs","Lite.Tests/McpWindowNoticeConfigAndLogToolTests.cs","Lite.Tests/McpWindowNoticeEventToolTests.cs","Lite.Tests/McpWindowNoticeSystemHealthToolTests.cs","Lite.Tests/McpWindowNoticeToolTests.cs","Lite.Tests/QueryWindowTruncationTests.cs","Lite.Tests/SharedDuckDbFixture.cs"],"failed_classes":["Darling.Tests.PlanSync4512CancellationTests"],"failed_jobs":["build"]} +{"date":"2026-10-05","head_sha":"b463460776890e2106577fb3c489717a7a6b6642","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingBlockingStatsReadTests.cs","Darling/Darling.Tests/DarlingCollectionLogReadTests.cs","Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs","Darling/Darling.Tests/DarlingMcpHealthParserToolsWindowNoticeTests.cs","Darling/Darling.Tests/DarlingQueryHeatmapTests.cs","Darling/Darling.Tests/DarlingQueryStoreRegressionsTests.cs","Darling/Darling.Tests/DarlingSignificantWaitsReadTests.cs","Darling/Darling.Tests/EffectiveStartUtcTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Lite.Tests/CollectionLogToolTests.cs","Lite.Tests/McpMissMessageParityPinTests.cs","Lite.Tests/McpWindowNoticeAggregateToolTests.cs","Lite.Tests/McpWindowNoticeConfigAndLogToolTests.cs","Lite.Tests/McpWindowNoticeSystemHealthToolTests.cs","Lite.Tests/McpWindowNoticeToolTests.cs","Lite.Tests/QueryStoreRegressionsToolTests.cs","Lite.Tests/SignificantWaitsToolTests.cs","Lite/Mcp/McpHealthParserTools.cs","Lite/Mcp/McpHealthTools.cs","Lite/Mcp/McpInstructions.cs","Lite/Mcp/McpQueryTools.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.ComposeDataFloorLiveTests","Darling.Tests.ComposedPanelNoticeZoneTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (2)"]} +{"date":"2026-10-05","head_sha":"ffac2c1f8b01c5788f3326692e58e702216dfa80","event":"pull_request","changed_files":["Darling/Darling.Tests/PgPlanViewerBehaviourTests.cs","Darling/Darling.Tests/WebPgPlanViewerPageTests.cs","Darling/Darling.Tests/web-blocking-save-xml-harness.mjs","Darling/Darling.Tests/web-collection-caveats-harness.mjs","Darling/Darling.Tests/web-deadlock-processes-harness.mjs","Darling/Darling.Tests/web-kept-history-harness.mjs","Darling/Darling.Tests/web-memory-pressure-harness.mjs","Darling/Darling.Tests/web-perfmon-harness.mjs","Darling/Darling.Tests/web-perfmon-multi-harness.mjs","Darling/Darling.Tests/web-pg-plan-viewer-harness.mjs","Darling/Darling.Tests/web-server-trends-harness.mjs","Darling/Darling.Tests/web-waits-activity-trends-harness.mjs","Darling/Darling.Tests/web-waits-harness.mjs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/pg-plan-viewer.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js"],"failed_classes":["Darling.Tests.ComposeDataFloorLiveTests","Darling.Tests.ComposedPanelNoticeZoneTests","Darling.Tests.WebCollectorRunHistoryTests"],"failed_jobs":["Darling PG tests (2)","build","Darling PG tests (0)"]} +{"date":"2026-10-06","head_sha":"6ac72f860523f66d8e29944dc094fa58c9a779f9","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpQueryStoreHistoryToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/EffectiveStartUtcTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreHistoryTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/PlanViewerBehaviourTests.cs","Darling/Darling.Tests/QueryStoreHistoryBehaviourTests.cs","Darling/Darling.Tests/QueryStoreHistoryLivePostgresTests.cs","Darling/Darling.Tests/QueryStoreHistoryPanelStateTests.cs","Darling/Darling.Tests/QueryStoreHistoryWideTierLiveTests.cs","Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs","Darling/Darling.Tests/WebPlanViewerPageTests.cs","Darling/Darling.Tests/WebQueryStoreHistoryColumnTests.cs","Darling/Darling.Tests/web-plan-viewer-harness.mjs","Darling/Darling.Tests/web-qs-history-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreHistoryTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/plan-viewer.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/query-store-history.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/README.md","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.StoreStatementStatsLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"7c4da8f91f653161036febf4b2d06ae4cd7ca776","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionLogDrainForensicsStoreTests.cs","Darling/Darling.Tests/PgSensitiveStatementFilterLiveTests.cs","Darling/Darling.Tests/PgSensitiveStatementFilterTests.cs","Darling/Darling.Tests/PgStatementTextFailureRecordingTests.cs","Darling/Darling.Tests/PgStatementTextHealthSemanticsTests.cs","Darling/Darling.Tests/PgStatementTextScrubLiveTests.cs","Darling/Darling.Tests/SensitiveStatementCorpus.cs","Darling/Darling.Tests/SensitiveStatementsParityLiveTests.cs","Darling/Darling.Tests/SensitiveStatementsTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/PgStatementTextScrub.cs","Darling/PerformanceMonitor.Darling.Storage/PgStatementText.cs","PerformanceMonitor.Collectors/PgSensitiveStatementFilter.cs","PerformanceMonitor.Common/SensitiveStatements.Regex.cs","PerformanceMonitor.Common/SensitiveStatements.cs"],"failed_classes":["Darling.Tests.StoreStatementStatsLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"33932296ca375d2debf70fcc2d217ec3852336dd","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpQueryStoreHistoryToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/EffectiveStartUtcTests.cs","Darling/Darling.Tests/McpPayloadContractCensusTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreHistoryTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/PlanViewerBehaviourTests.cs","Darling/Darling.Tests/QueryStoreHistoryBehaviourTests.cs","Darling/Darling.Tests/QueryStoreHistoryLivePostgresTests.cs","Darling/Darling.Tests/QueryStoreHistoryPanelStateTests.cs","Darling/Darling.Tests/QueryStoreHistoryWideTierLiveTests.cs","Darling/Darling.Tests/RawWindowFloorQuietStartTests.cs","Darling/Darling.Tests/WebPlanViewerPageTests.cs","Darling/Darling.Tests/WebQueryStoreHistoryColumnTests.cs","Darling/Darling.Tests/web-plan-viewer-harness.mjs","Darling/Darling.Tests/web-qs-history-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreHistoryTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/plan-viewer.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/query-store-history.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/README.md","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.StoreStatementStatsLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"fa3497bb39b888a14e575841191250e5ca4cfcb5","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/McpPageContractTests.cs","Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorRoutesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/Darling.Tests/QueryStoreTopDailyReadLiveTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/TopFillEmptyRoundLiveTests.cs","Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingTests.cs","Darling/Darling.Tests/TopRankingDifferentialLiveTests.cs","Darling/Darling.Tests/TopRankingHourlyLiveTests.cs","Darling/Darling.Tests/TopRankingLiveTests.cs","Darling/Darling.Tests/TopRankingNoticeBehaviourTests.cs","Darling/Darling.Tests/TopRankingOnePassBaseline.cs","Darling/Darling.Tests/TopRankingPagePinTests.cs","Darling/Darling.Tests/TopRankingTests.cs","Darling/Darling.Tests/TopRankingTwoPassTests.cs","Darling/Darling.Tests/TopTextLookupFillTests.cs","Darling/Darling.Tests/TopTextLookupPlanLiveTests.cs","Darling/Darling.Tests/TopTextTieBreakLiveTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/Darling.Tests/WebTopProceduresColumnsTests.cs","Darling/Darling.Tests/WebTopQueriesColumnsTests.cs","Darling/Darling.Tests/top-ranking-notice-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/TopRanking.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Storage/TopFill.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Lite.Tests/TopListTotalOrderPinTests.cs","Lite.Tests/TopListWaitforRefillReaderTests.cs","Lite.Tests/TopTextTieBreakReaderTests.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.QueryStore.cs","Lite/Services/TopFill.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.LivePlanShapeIsolationCensusTests","Darling.Tests.WebTopProceduresColumnsTests"],"failed_jobs":["Darling PG tests (2)","build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-10-06","head_sha":"3af3ade9ea1061fd96eca0b2ee1ece351efe17c3","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/McpPageContractTests.cs","Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorRoutesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/Darling.Tests/QueryStoreTopDailyReadLiveTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/TopFillEmptyRoundLiveTests.cs","Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingTests.cs","Darling/Darling.Tests/TopRankingDifferentialLiveTests.cs","Darling/Darling.Tests/TopRankingHourlyLiveTests.cs","Darling/Darling.Tests/TopRankingLiveTests.cs","Darling/Darling.Tests/TopRankingNoticeBehaviourTests.cs","Darling/Darling.Tests/TopRankingOnePassBaseline.cs","Darling/Darling.Tests/TopRankingPagePinTests.cs","Darling/Darling.Tests/TopRankingTests.cs","Darling/Darling.Tests/TopRankingTwoPassTests.cs","Darling/Darling.Tests/TopTextLookupFillTests.cs","Darling/Darling.Tests/TopTextLookupPlanLiveTests.cs","Darling/Darling.Tests/TopTextTieBreakLiveTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/Darling.Tests/WebTopProceduresColumnsTests.cs","Darling/Darling.Tests/WebTopQueriesColumnsTests.cs","Darling/Darling.Tests/top-ranking-notice-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/TopRanking.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Storage/TopFill.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Lite.Tests/TopListTotalOrderPinTests.cs","Lite.Tests/TopListWaitforRefillReaderTests.cs","Lite.Tests/TopTextTieBreakReaderTests.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.QueryStore.cs","Lite/Services/TopFill.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.LivePlanShapeIsolationCensusTests","Darling.Tests.StoreStatementStatsLiveTests","Darling.Tests.WebTopProceduresColumnsTests"],"failed_jobs":["Darling PG tests (2)","build","Darling PG tests (1)","Darling PG tests (0)"]} +{"date":"2026-10-06","head_sha":"19936ab8788df98247728fbfb4d6856d211168f5","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/DarlingCustomViewsTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/McpPageContractTests.cs","Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorRoutesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideGridLiveTests.cs","Darling/Darling.Tests/QueryStoreTopDailyReadLiveTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/TopFillEmptyRoundLiveTests.cs","Darling/Darling.Tests/TopProceduresHourlyRoutingTests.cs","Darling/Darling.Tests/TopQueriesHourlyRoutingTests.cs","Darling/Darling.Tests/TopRankingDifferentialLiveTests.cs","Darling/Darling.Tests/TopRankingHourlyLiveTests.cs","Darling/Darling.Tests/TopRankingLiveTests.cs","Darling/Darling.Tests/TopRankingNoticeBehaviourTests.cs","Darling/Darling.Tests/TopRankingOnePassBaseline.cs","Darling/Darling.Tests/TopRankingPagePinTests.cs","Darling/Darling.Tests/TopRankingTests.cs","Darling/Darling.Tests/TopRankingTwoPassTests.cs","Darling/Darling.Tests/TopTextLookupFillTests.cs","Darling/Darling.Tests/TopTextLookupPlanLiveTests.cs","Darling/Darling.Tests/TopTextTieBreakLiveTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/Darling.Tests/WebTopProceduresColumnsTests.cs","Darling/Darling.Tests/WebTopQueriesColumnsTests.cs","Darling/Darling.Tests/top-ranking-notice-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/TopRanking.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Storage/TopFill.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs","Lite.Tests/TopListTotalOrderPinTests.cs","Lite.Tests/TopListWaitforRefillReaderTests.cs","Lite.Tests/TopTextTieBreakReaderTests.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.QueryStore.cs","Lite/Services/TopFill.cs"],"failed_classes":["Darling.Tests.LiveCleanupConversionRatchetTests","Darling.Tests.LivePlanShapeIsolationCensusTests","Darling.Tests.WebTopProceduresColumnsTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)","Darling PG tests (2)"]} +{"date":"2026-10-06","head_sha":"51ccf6b024b2fd53c1ab97b345bb0dcbacceaf5c","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingConfigTests.cs","Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/ServerAddRouteTests.cs","Darling/Darling.Tests/ServerAddViewerRoleLiveTests.cs","Darling/Darling.Tests/ServerEditCoreTests.cs","Darling/Darling.Tests/ServerEditGateLiveTests.cs","Darling/Darling.Tests/ServerEditLiveTests.cs","Darling/Darling.Tests/ServerEditPasswordRuleViewerTests.cs","Darling/Darling.Tests/ServerEditRouteTests.cs","Darling/Darling.Tests/ServerEditViewerRoleLiveTests.cs","Darling/Darling.Tests/ServerIdentityLockLiveTests.cs","Darling/Darling.Tests/ServerIdentityLockSourcePinTests.cs","Darling/Darling.Tests/StartupCommandTimeoutTests.cs","Darling/Darling.Tests/ViewerAddServerIdCollisionTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.DarlingReadsUseOnlyStoreViewsTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-06","head_sha":"6866eb5664a378aa35f09c7576b9e883e2fdc177","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/SensitiveStatementHostFilterTests.cs","Darling/Darling.Tests/SensitiveStatementOutputFilterTests.cs","Darling/Darling.Tests/SensitiveStatementSessionTests.cs","Darling/Darling.Tests/StatementColumnCensusTests.cs","Darling/Darling.Tests/StatementFilterCensus.cs","Darling/Darling.Tests/StatementScrubRecordingWriter.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Lite/Mcp/McpPlanTools.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Common/Mcp/SensitiveStatementOutputFilter.cs","PerformanceMonitor.Common/SensitiveStatements.Session.cs","PerformanceMonitor.Common/SensitiveStatements.cs"],"failed_classes":["PerformanceMonitorLite.Tests.PerfmonCounterTypeTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-10-06","head_sha":"c09289f16cc2eaec99f49983b1eacaafbc53eabd","event":"pull_request","changed_files":["Darling/Darling.Tests/BlockingListPlanFlagsLivePostgresTests.cs","Darling/Darling.Tests/DarlingBlockingReaderServerClockTests.cs","Darling/Darling.Tests/DarlingMcpBlockingToolsTests.cs","Darling/Darling.Tests/DarlingMcpPlanToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPlanTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/PlanViewerBehaviourTests.cs","Darling/Darling.Tests/RowPlanReadLivePostgresTests.cs","Darling/Darling.Tests/WebPlanViewerPageTests.cs","Darling/Darling.Tests/WebReadCancellationPinTests.cs","Darling/Darling.Tests/web-plan-viewer-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingBlockingReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpBlockingTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInstructions.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoredPlanReader.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/plan-viewer.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/README.md","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","README.md","llms.txt"],"failed_classes":["Darling.Tests.DarlingReadsUseOnlyStoreViewsTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-06","head_sha":"817a583826610decb34e0d7b00bcc5c6cbb285e8","event":"pull_request","changed_files":[".claude/skills/release-checklist/SKILL.md","Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/SensitiveStatementHostFilterTests.cs","Darling/Darling.Tests/SensitiveStatementOutputFilterTests.cs","Darling/Darling.Tests/SensitiveStatementSessionTests.cs","Darling/Darling.Tests/StatementCollectionCensusCases.cs","Darling/Darling.Tests/StatementColumnCensusTests.cs","Darling/Darling.Tests/StatementFilterCensus.cs","Darling/Darling.Tests/StatementScrubRecordingWriter.cs","Darling/Darling.Tests/StoredPlanAnalysisFilterTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Lite.Tests/Lite.Tests.csproj","Lite.Tests/PerfmonCounterTypeTests.cs","Lite.Tests/StoredPlanAnalysisFilterTests.cs","Lite/Mcp/McpPlanTools.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PerfmonStatsCollector.cs","PerformanceMonitor.Common/Mcp/SensitiveStatementOutputFilter.cs","PerformanceMonitor.Common/PerfmonCounterTypes.cs","PerformanceMonitor.Common/SensitiveStatements.Session.cs","PerformanceMonitor.Common/SensitiveStatements.cs"],"failed_classes":["Lite.Tests.DarlingReadsTraitGuardTests"],"failed_jobs":["Lite tests (1)"]} +{"date":"2026-10-06","head_sha":"d1343812746131b19b0192ab42a25527b784904b","event":"pull_request","changed_files":["Darling/Darling.Tests/ActiveQueriesDatabaseFilterLiveTests.cs","Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/DarlingWebHostGateLiveTests.cs","Darling/Darling.Tests/DarlingWebOidcTests.cs","Darling/Darling.Tests/DatabaseFilterBindingLiveTests.cs","Darling/Darling.Tests/ExcludedDatabasesTests.cs","Darling/Darling.Tests/McpPageContractTests.cs","Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorLiveTests.cs","Darling/Darling.Tests/QueryStoreReadsDatabaseFilterLiveTests.cs","Darling/Darling.Tests/QueryStoreTopDailyReadLiveTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/ServerDatabasesEndpointLiveTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/TopQueriesDatabaseFilterLiveTests.cs","Darling/Darling.Tests/TopRankingTwoPassTests.cs","Darling/Darling.Tests/TopTextTieBreakLiveTests.cs","Darling/Darling.Tests/ViewerLocalStateBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterChipsBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterDispatchLiveTests.cs","Darling/Darling.Tests/WebDatabaseFilterPickerBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterReadsTests.cs","Darling/Darling.Tests/WebDatabaseFilterTests.cs","Darling/Darling.Tests/viewer-local-harness.mjs","Darling/Darling.Tests/web-database-filter-chips-harness.mjs","Darling/Darling.Tests/web-database-filter-harness.mjs","Darling/Darling.Tests/web-database-filter-picker-harness.mjs","Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFleetSweepEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerDatabasesEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreRegressionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingRuntimePrecondition.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSessionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/database-filter-reads.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/multi-picker.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/analysis-findings.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/database-filter.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/viewer-local.js","Darling/PerformanceMonitor.Darling.Storage/CollectedDatabases.cs","Darling/PerformanceMonitor.Darling.Storage/DatabaseFilter.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.PgIndexBloatCoverageTests","PerformanceMonitor.Darling.Tests.TopTextLookupPlanLiveTests"],"failed_jobs":["Darling PG tests (2)","build","Darling PG tests (0)"]} +{"date":"2026-10-06","head_sha":"2090a54247c30e9fb6d98a45598f8f7bdcc80890","event":"pull_request","changed_files":["Darling/Darling.Tests/ActiveQueriesDatabaseFilterLiveTests.cs","Darling/Darling.Tests/ComposeQueryStoreWideExactnessLiveTests.cs","Darling/Darling.Tests/DarlingMcpDataToolsTests.cs","Darling/Darling.Tests/DarlingWebEndpointsTests.cs","Darling/Darling.Tests/DarlingWebHostGateLiveTests.cs","Darling/Darling.Tests/DarlingWebOidcTests.cs","Darling/Darling.Tests/DatabaseFilterBindingLiveTests.cs","Darling/Darling.Tests/ExcludedDatabasesTests.cs","Darling/Darling.Tests/McpPageContractTests.cs","Darling/Darling.Tests/PgIndexBloatCoverageTests.cs","Darling/Darling.Tests/QueryStoreBackgroundIndexesLiveTests.cs","Darling/Darling.Tests/QueryStoreIntervalWideBelowFloorLiveTests.cs","Darling/Darling.Tests/QueryStoreReadsDatabaseFilterLiveTests.cs","Darling/Darling.Tests/QueryStoreTopDailyReadLiveTests.cs","Darling/Darling.Tests/QueryStoreTopMcpLiveTests.cs","Darling/Darling.Tests/QueryStoreTopWebDefaultTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/ServerDatabasesEndpointLiveTests.cs","Darling/Darling.Tests/ServerPageTabsTests.cs","Darling/Darling.Tests/SlowReadLogTests.cs","Darling/Darling.Tests/TopQueriesDatabaseFilterLiveTests.cs","Darling/Darling.Tests/TopRankingTwoPassTests.cs","Darling/Darling.Tests/TopTextLookupPlanLiveTests.cs","Darling/Darling.Tests/TopTextTieBreakLiveTests.cs","Darling/Darling.Tests/ViewerLocalStateBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseBlankRuleTests.cs","Darling/Darling.Tests/WebDatabaseFilterBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterChipsBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterDispatchLiveTests.cs","Darling/Darling.Tests/WebDatabaseFilterPickerBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterReadsTests.cs","Darling/Darling.Tests/WebDatabaseFilterTests.cs","Darling/Darling.Tests/viewer-local-blank-harness.mjs","Darling/Darling.Tests/viewer-local-harness.mjs","Darling/Darling.Tests/web-database-filter-chips-harness.mjs","Darling/Darling.Tests/web-database-filter-harness.mjs","Darling/Darling.Tests/web-database-filter-picker-harness.mjs","Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFleetSweepEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerDatabasesEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryHeatmapTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreRegressionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpSessionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryHeatmapReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingQueryStoreRegressionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingRuntimePrecondition.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingSessionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs","Darling/PerformanceMonitor.Darling.Service/SlowReadLog.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/database-filter-reads.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/multi-picker.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/analysis-findings.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/database-filter.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/viewer-local.js","Darling/PerformanceMonitor.Darling.Storage/CollectedDatabases.cs","Darling/PerformanceMonitor.Darling.Storage/DatabaseFilter.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ExcludedDatabases.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs"],"failed_classes":["Darling.Tests.WebDatabaseFilterReadsTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-10-06","head_sha":"b4c074a00f6f45da7f674ee8a036e4bb5e53bf73","event":"pull_request","changed_files":[".claude/skills/release-checklist/SKILL.md",".github/workflows/build.yml","Darling/Darling.Tests/CollectorMeasurementSeamTests.cs","Darling/Darling.Tests/SensitiveStatementHostFilterTests.cs","Darling/Darling.Tests/SensitiveStatementOutputFilterTests.cs","Darling/Darling.Tests/SensitiveStatementSessionTests.cs","Darling/Darling.Tests/StatementCollectionCensusCases.cs","Darling/Darling.Tests/StatementColumnCensusTests.cs","Darling/Darling.Tests/StatementFilterCensus.cs","Darling/Darling.Tests/StatementScrubRecordingWriter.cs","Darling/Darling.Tests/StoredPlanAnalysisFilterTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHostService.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Lite.Tests/Lite.Tests.csproj","Lite.Tests/PerfmonCounterTypeTests.cs","Lite.Tests/StoredPlanAnalysisFilterTests.cs","Lite/Mcp/McpPlanTools.cs","PerformanceMonitor.Collectors/CollectorContext.cs","PerformanceMonitor.Collectors/PerfmonStatsCollector.cs","PerformanceMonitor.Common/Mcp/SensitiveStatementOutputFilter.cs","PerformanceMonitor.Common/PerfmonCounterTypes.cs","PerformanceMonitor.Common/SensitiveStatements.Session.cs","PerformanceMonitor.Common/SensitiveStatements.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["Darling PG tests (2)","build","Darling whole-tree guards"]} +{"date":"2026-10-06","head_sha":"1e6f65cf044e1be9ea47d3c981ae33a5e9734b15","event":"pull_request","changed_files":["Darling/Darling.Tests/AdminPageBehaviourTests.cs","Darling/Darling.Tests/AdminPageTests.cs","Darling/Darling.Tests/AdminServerEditBehaviourTests.cs","Darling/Darling.Tests/AdminServersRouteTests.cs","Darling/Darling.Tests/ManageTagsBehaviourTests.cs","Darling/Darling.Tests/ManageTagsPageTests.cs","Darling/Darling.Tests/admin-page-harness.mjs","Darling/Darling.Tests/admin-server-edit-harness.mjs","Darling/Darling.Tests/manage-tags-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingAdminServersReader.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/admin.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/manage-tags.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js"],"failed_classes":["Darling.Tests.FleetIdentifierScrubTests"],"failed_jobs":["build","Darling PG tests (0)"]} +{"date":"2026-10-06","head_sha":"d0552429dd7909d94539d7d752eb2cfd8fd1b948","event":"pull_request","changed_files":["Darling/Darling.Tests/PagerDutyAutoResolveRungTests.cs","Darling/Darling.Tests/QueryStatsHourLedgerTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/tools/provision-roles.sql","Lite.Tests/PagerDutyWebhookTests.cs","Lite/App.xaml.cs","Lite/Services/AppAlertSettings.cs","Lite/Windows/SettingsWindow.xaml","Lite/Windows/SettingsWindow.xaml.cs","Lite/config/settings.sample.json","PerformanceMonitor.Notifications/IAlertSettings.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs"],"failed_classes":["Darling.Tests.CaggGroupIndexDropLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"f2dcc58e9d45a2f14353d3ede4c00c28d6c14c43","event":"pull_request","changed_files":["Darling/Darling.Tests/PagerDutyAutoResolveRungTests.cs","Darling/Darling.Tests/QueryStatsHourLedgerTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/tools/provision-roles.sql","Lite.Tests/PagerDutyWebhookTests.cs","Lite/App.xaml.cs","Lite/Services/AppAlertSettings.cs","Lite/Windows/SettingsWindow.xaml","Lite/Windows/SettingsWindow.xaml.cs","Lite/config/settings.sample.json","PerformanceMonitor.Notifications/IAlertSettings.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs"],"failed_classes":["Darling.Tests.CaggGroupIndexDropLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"3925bff0a2a8144e8ce2849c0b5cd88ee02468bf","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/OwnedSecretReferenceRefusalTests.cs","Darling/Darling.Tests/ServerAddViewerRoleLiveTests.cs","Darling/Darling.Tests/ServerEditCoreTests.cs","Darling/Darling.Tests/ServerEditLiveTests.cs","Darling/Darling.Tests/ServerPasswordFromRequestTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecretSource.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"71e66d9d2df0a6df2f2003b4e94679ef73cea6a1","event":"push","changed_files":["Darling/Darling.Tests/DarlingMcpObjectStatsToolsTests.cs","Darling/Darling.Tests/DatabaseNameParamTwinTests.cs","Darling/Darling.Tests/FinOpsIndexAnalysisLimitBehaviourTests.cs","Darling/Darling.Tests/FinOpsLockingDetailBehaviourTests.cs","Darling/Darling.Tests/FinOpsLockingHeatBehaviourTests.cs","Darling/Darling.Tests/FinOpsLockingPickerBehaviourTests.cs","Darling/Darling.Tests/FinOpsPageShellTests.cs","Darling/Darling.Tests/FinOpsTabIndexAnalysisPageTests.cs","Darling/Darling.Tests/FinOpsTabLockingPageTests.cs","Darling/Darling.Tests/IndexUsageTruncationTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpObjectStatsTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/ObjectLockingDetailLiveTests.cs","Darling/Darling.Tests/ObjectLockingHeatLiveTests.cs","Darling/Darling.Tests/ObjectLockingWebDefaultTests.cs","Darling/Darling.Tests/ObjectStatsDatabaseFilterLiveTests.cs","Darling/Darling.Tests/SeparatelyMonitoredListNoteTests.cs","Darling/Darling.Tests/WebHarness/load-web-modules.mjs","Darling/Darling.Tests/finops-index-analysis-limit-harness.mjs","Darling/Darling.Tests/finops-locking-detail-harness.mjs","Darling/Darling.Tests/finops-locking-heat-harness.mjs","Darling/Darling.Tests/finops-locking-picker-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpObjectStatsTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingObjectStatsReader.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/database-filter-reads.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/database-box.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/locking.js","Lite.Tests/McpToolsListBudget/McpObjectStatsTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/ObjectLockingBudgetTests.cs","Lite.Tests/OptimizedLockingNoteTests.cs","Lite/Controls/FinOpsTab.Locking.cs","Lite/Mcp/McpObjectStatsTools.cs","Lite/Services/LocalDataService.FinOps.IndexObjects.cs"],"failed_classes":["PerformanceMonitor.Darling.Tests.RetiredBaselineAggregateLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-06","head_sha":"ab17f612435931e266ace9482ff22f2e519459b7","event":"pull_request","changed_files":["Lite.Tests/ArchiveNoOpPassViewRebuildTests.cs","Lite.Tests/PostCollectionCheckpointBudgetTests.cs","Lite/Services/ArchiveService.cs","Lite/Services/RemoteCollectorService.cs"],"failed_classes":["Darling.Tests.TsqlConventionGuardTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"9be59f1a956a973199382e71ac3b7073b876bf15","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPasswordKeyStateTests.cs","Darling/Darling.Tests/PasswordBindingCensusTests.cs","Darling/Darling.Tests/PasswordSealTests.cs","Darling/Darling.Tests/TestData/password-seal-v1-vector.json","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeyState.cs","Darling/PerformanceMonitor.Darling.Storage/PasswordBinding.cs","Darling/PerformanceMonitor.Darling.Storage/PasswordSeal.cs","Darling/PerformanceMonitor.Darling.Storage/ServerConnectionIdentity.cs"],"failed_classes":["Darling.Tests.CollectionLogDrainForensicsStoreTests","Darling.Tests.FleetIdentifierScrubTests","Darling.Tests.SwallowedItemFailureTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (2)"]} +{"date":"2026-10-06","head_sha":"398056f9681b10ce4bf12a873b07062caf9d696a","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPasswordKeyFileHardeningTests.cs","Darling/Darling.Tests/DarlingPasswordKeyFileTests.cs","Darling/Darling.Tests/DarlingPasswordKeyFileUnixTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/PgLogHashKeyTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFileSecurity.cs","Darling/PerformanceMonitor.Darling.Service/DarlingLogHashKeyFile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingOwnedSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeyFile.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServiceKeyFile.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests","Darling.Tests.PayloadDimensionTests","Darling.Tests.PgTargetQueriesTests","Lite.Tests.DarlingReadsTraitGuardTests"],"failed_jobs":["Lite tests (3)","build","Darling PG tests (2)","Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"31f07a7e20050bb78173e267a0360496dd605ba3","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/RegisteredServerSettingDriftTests.cs","Darling/Darling.Tests/SelfManagedStoreRulesStartPinTests.cs","Darling/Darling.Tests/ServerEditCoreTests.cs","Darling/Darling.Tests/ServerEditLiveTests.cs","Darling/Darling.Tests/ServerEditPasswordRuleViewerTests.cs","Darling/Darling.Tests/ServerIdentityLockLiveTests.cs","Darling/Darling.Tests/ServerPasswordFromRequestTests.cs","Darling/Darling.Tests/StoreConfigProviderConnectionMatchTests.cs","Darling/Darling.Tests/StoreServerPasswordRulesLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecretSource.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/ServerConnectionRule.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"c62b5e8f3123cf73233e87e70c4a7944610eedf9","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/RegisteredServerSettingDriftTests.cs","Darling/Darling.Tests/SelfManagedStoreRulesStartPinTests.cs","Darling/Darling.Tests/ServerEditCoreTests.cs","Darling/Darling.Tests/ServerEditLiveTests.cs","Darling/Darling.Tests/ServerEditPasswordRuleViewerTests.cs","Darling/Darling.Tests/ServerIdentityLockLiveTests.cs","Darling/Darling.Tests/ServerPasswordFromRequestTests.cs","Darling/Darling.Tests/StoreConfigProviderConnectionMatchTests.cs","Darling/Darling.Tests/StoreServerPasswordRulesLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecretSource.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/ServerConnectionRule.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"7d993a7f07e13a999df9556d9e4da5c097585ce2","event":"pull_request","changed_files":["Darling/Darling.Tests/ComposeCatalogStatementTextCensusTests.cs","Darling/Darling.Tests/DiagnosticsBundleServiceLogTests.cs","Darling/Darling.Tests/DiagnosticsBundleStatementFilterTests.cs","Darling/Darling.Tests/SensitiveStatementHostFilterTests.cs","Darling/Darling.Tests/StatementColumnCensusTests.cs","Darling/Darling.Tests/StatementFilterAnalysisReadsLiveTests.cs","Darling/Darling.Tests/StatementFilterBlockingReadsLiveTests.cs","Darling/Darling.Tests/StatementFilterCensus.cs","Darling/Darling.Tests/StatementFilterDrillDownWarningsLiveTests.cs","Darling/Darling.Tests/StatementFilterPlanReaderSourceScanTests.cs","Darling/Darling.Tests/StatementFilterPlanReadsLiveTests.cs","Darling/Darling.Tests/StatementFilterQueryReadsLiveTests.cs","Darling/Darling.Tests/StatementFilterWebTests.cs","Darling/Darling.Tests/StoredPlanAnalysisFilterTests.cs","Darling/Darling.Tests/ViewerStoreLoginRawReadLiveTests.cs","Darling/Darling.Tests/WebRouteStatementCensusTests.cs","Darling/Darling.Tests/web-plan-viewer-harness.mjs","Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Plans.cs","Darling/PerformanceMonitor.Darling.Service/AlertNotebookEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingFleetSweepEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebDeadlockGraph.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebStatementSweep.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleAliaser.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleServiceLog.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingStoredPlanReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/GcfCallToolFilter.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/GcfOutput.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/IGcfOutputFormat.cs","Darling/PerformanceMonitor.Darling.Service/SlowReadLog.cs","Lite.Tests/StatementFilterDrillDownWarningsTests.cs","Lite.Tests/StoredPlanAnalysisFilterTests.cs","Lite/Analysis/DrillDownCollector.Plans.cs"],"failed_classes":["Darling.Tests.PlanRegressionReadShapeTests","Darling.Tests.StatementFilterBlockingReadsLiveTests","Darling.Tests.TestsNoSilentPassGuardTests"],"failed_jobs":["Darling PG tests (0)","build","Darling PG tests (2)"]} +{"date":"2026-10-06","head_sha":"8f95de197e0d1d82b2454828d8161e7d5f2ce4e0","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingManagedRolesTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/PasswordBindingCensusTests.cs","Darling/Darling.Tests/RegisteredServerSettingDriftTests.cs","Darling/Darling.Tests/RepoFileAdoptionTests.cs","Darling/Darling.Tests/SelfManagedStoreRulesStartPinTests.cs","Darling/Darling.Tests/ServerEditCoreTests.cs","Darling/Darling.Tests/ServerEditLiveTests.cs","Darling/Darling.Tests/ServerEditPasswordRuleViewerTests.cs","Darling/Darling.Tests/ServerIdentityLockLiveTests.cs","Darling/Darling.Tests/ServerPasswordFromRequestTests.cs","Darling/Darling.Tests/StoreConfigProviderConnectionMatchTests.cs","Darling/Darling.Tests/StoreServerPasswordRulesLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecretSource.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/ServerConnectionRule.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs","Darling/tools/provision-roles.sql"],"failed_classes":["Darling.Tests.StatementFilterAlertTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"7fc8b09c96fb66c13665a2a0e28c47060475ea09","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingRuntimePreflightTests.cs","Darling/README.md","Darling/tools/install-darling.ps1","docs/uat-onboarding.md"],"failed_classes":["PerformanceMonitor.Darling.Tests.RetiredBaselineAggregateLiveTests"],"failed_jobs":["Darling PG tests (2)"]} +{"date":"2026-10-06","head_sha":"2000eb7f3114ebf436f939145a87fa2191012332","event":"pull_request","changed_files":["Lite.Tests/QueryStoreDedupReadTests.cs","Lite.Tests/QueryStoreOldReadSql.cs","Lite.Tests/QueryStoreTextAfterRankingTests.cs","Lite.Tests/TopListTotalOrderPinTests.cs","Lite/Services/LocalDataService.QueryStore.cs","Lite/Services/LocalDataService.QueryStoreRegressions.cs","Lite/Services/LocalDataService.QueryStoreText.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","PerformanceMonitorLite.Tests.DuckDbInitializerDisposalGuardTests"],"failed_jobs":["build","Lite tests (3)"]} +{"date":"2026-10-06","head_sha":"8c842bbc3f7d574d1a0b2699388dbd253c85ccdb","event":"pull_request","changed_files":["Lite.Tests/QueryStoreDedupReadTests.cs","Lite.Tests/QueryStoreOldReadSql.cs","Lite.Tests/QueryStoreTextAfterRankingTests.cs","Lite.Tests/TopListTotalOrderPinTests.cs","Lite/Services/LocalDataService.QueryStore.cs","Lite/Services/LocalDataService.QueryStoreRegressions.cs","Lite/Services/LocalDataService.QueryStoreText.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests","PerformanceMonitorLite.Tests.DuckDbInitializerDisposalGuardTests"],"failed_jobs":["Lite tests (1)","build"]} +{"date":"2026-10-06","head_sha":"38445f2ebac66381fa45de8a9665ee710848772a","event":"pull_request","changed_files":["Lite.Tests/ArchiveCopyOptionsPinTests.cs","Lite.Tests/ArchiveTieOrderTests.cs","Lite.Tests/MainConnectionLimitsTests.cs","Lite.Tests/QueryStoreSliceRepairTests.cs","Lite/Analysis/DrillDownCollector.Queries.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/ArchiveService.cs","Lite/Services/DataImportService.cs","Lite/Services/ParquetCompaction.cs","Lite/Services/QueryStoreSliceRepairService.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["build"]} +{"date":"2026-10-06","head_sha":"358b0e5005a18ba8a1022dd7201f4acd3b6d1e7d","event":"pull_request","changed_files":["Lite.Tests/ArchiveCopyOptionsPinTests.cs","Lite.Tests/ArchiveTieOrderTests.cs","Lite.Tests/MainConnectionLimitsTests.cs","Lite.Tests/QueryStoreSliceRepairTests.cs","Lite/Analysis/DrillDownCollector.Queries.cs","Lite/Analysis/DuckDbFactCollector.QueryPerf.cs","Lite/Database/DuckDbInitializer.cs","Lite/Services/ArchiveService.cs","Lite/Services/DataImportService.cs","Lite/Services/ParquetCompaction.cs","Lite/Services/QueryStoreSliceRepairService.cs"],"failed_classes":["Darling.Tests.CommentFilterAdoptionTests"],"failed_jobs":["build"]} +{"date":"2026-10-06","head_sha":"b1a5999ded26ecac4045cf86957f8ec1638aa2b6","event":"pull_request","changed_files":["Lite.Tests/CollectionHealthKeyedLookupParityTests.cs","Lite.Tests/CollectionHealthOracleSql.cs","Lite.Tests/CollectionHealthReadBehaviourTests.cs","Lite.Tests/CollectorHealthClassifierTests.cs","Lite.Tests/DataStartBannerProbeFailureTests.cs","Lite.Tests/DataStartBannerQueriesTabTests.cs","Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/EmptyEnumerationNoteTests.cs","Lite.Tests/RegressedFromProductiveTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Database/DuckDbInitializer.cs","Lite/Helpers/MethodProfiler.cs","Lite/Services/LocalDataService.CollectionHealth.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","Lite/Services/LocalDataService.cs","Lite/Services/ReadPhaseTimer.cs"],"failed_classes":["Darling.Tests.CollectionOutputBesideCostTests"],"failed_jobs":["build"]} +{"date":"2026-10-06","head_sha":"d9274d2e430aa20a386bcf655816ddb037bcb3f6","event":"pull_request","changed_files":["Lite.Tests/CollectionHealthKeyedLookupParityTests.cs","Lite.Tests/CollectionHealthOracleSql.cs","Lite.Tests/CollectionHealthReadBehaviourTests.cs","Lite.Tests/CollectorHealthClassifierTests.cs","Lite.Tests/DataStartBannerProbeFailureTests.cs","Lite.Tests/DataStartBannerQueriesTabTests.cs","Lite.Tests/DataStartBannerSurfaceTests.cs","Lite.Tests/EmptyEnumerationNoteTests.cs","Lite.Tests/RegressedFromProductiveTests.cs","Lite/Controls/ServerTab.Refresh.cs","Lite/Database/DuckDbInitializer.cs","Lite/Helpers/MethodProfiler.cs","Lite/Services/LocalDataService.CollectionHealth.cs","Lite/Services/LocalDataService.QueryWindowFloor.cs","Lite/Services/LocalDataService.cs","Lite/Services/ReadPhaseTimer.cs"],"failed_classes":["Darling.Tests.CollectionOutputBesideCostTests"],"failed_jobs":["build"]} +{"date":"2026-10-06","head_sha":"bf8c2cb3eee49aac3e8570a59373b0550f71426e","event":"pull_request","changed_files":[".github/workflows/build.yml","Darling/Darling.Tests/DarlingAlertingTests.cs","Darling/Darling.Tests/DarlingCliSealKeyTests.cs","Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs","Darling/Darling.Tests/DarlingConfigTests.cs","Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs","Darling/Darling.Tests/DarlingPasswordKeyRuntimeTests.cs","Darling/Darling.Tests/DarlingPasswordKeyStoreLiveTests.cs","Darling/Darling.Tests/DarlingSecretsDecryptFailureTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/DarlingWebhookSecretsTests.cs","Darling/Darling.Tests/DiagnosticsBundleSealedTextTests.cs","Darling/Darling.Tests/DiagnosticsBundleWebhookOpenTests.cs","Darling/Darling.Tests/Fixtures/PasswordSeal/linux-self-check-vector.json","Darling/Darling.Tests/GenericWebhookHeadersOriginTests.cs","Darling/Darling.Tests/LegacyPinResolverLiveTests.cs","Darling/Darling.Tests/NotificationRoutesRungTests.cs","Darling/Darling.Tests/OwnedSecretReferenceRefusalTests.cs","Darling/Darling.Tests/PasswordBindingCensusTests.cs","Darling/Darling.Tests/PasswordKeyGrantParityTests.cs","Darling/Darling.Tests/PasswordKeyRungTests.cs","Darling/Darling.Tests/PasswordKeySeamLiveTests.cs","Darling/Darling.Tests/PasswordKeySelfCheckTests.cs","Darling/Darling.Tests/PasswordKeyTablesLiveTests.cs","Darling/Darling.Tests/QueryStatsHourLedgerTests.cs","Darling/Darling.Tests/SealedSecretResolverTests.cs","Darling/Darling.Tests/ServerAddViewerRoleLiveTests.cs","Darling/Darling.Tests/ServerEditCoreTests.cs","Darling/Darling.Tests/ServerEditLiveTests.cs","Darling/Darling.Tests/ServerEditPasswordRuleViewerTests.cs","Darling/Darling.Tests/ServerEditViewerRoleLiveTests.cs","Darling/Darling.Tests/ServerIdentityLockLiveTests.cs","Darling/Darling.Tests/ServerPasswordFromRequestTests.cs","Darling/Darling.Tests/StoreConfigProviderConnectionMatchTests.cs","Darling/Darling.Tests/StoreIsOnThisMachineTests.cs","Darling/Darling.Tests/TestKeyRings.cs","Darling/Darling.Tests/ValidateConfigOldFormatPasswordLiveTests.cs","Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs","Darling/Darling.Tests/ViewerPasswordKeyTests.cs","Darling/Darling.Tests/ViewerServerManagementTests.cs","Darling/Darling.Tests/ViewerServerSealLiveTests.cs","Darling/Darling.Tests/ViewerSmtpSealTests.cs","Darling/Darling.Tests/ViewerWebhookSealLiveTests.cs","Darling/Darling.Tests/ViewerWebhookSealingTests.cs","Darling/Darling.Tests/WebhookDeliverySnapshotTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliSealKey.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeyRuntime.cs","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeySelfCheck.cs","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeyStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebhookSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundle.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleRunner.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/SecretTextGuard.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PasswordBinding.cs","Darling/PerformanceMonitor.Darling.Storage/PasswordKeyTables.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/ServerConnectionRule.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/NotificationRouteEditDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/PasswordKeyChangedDialog.xaml","Darling/PerformanceMonitor.Darling.Viewer/PasswordKeyChangedDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerControlPlaneMigration.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PasswordKey.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerPasswordKey.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerPasswordKeyPins.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerMigration.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSecret.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerSmtpSeal.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerWebhookSealing.cs","Darling/README.md","Darling/compose/docker-compose.yml","Darling/compose/secrets/README.md","Darling/tools/provision-roles.sql","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","Lite.Tests/GenericWebhookHeadersOriginTests.cs","PerformanceMonitor.Notifications/IAlertSettings.cs","PerformanceMonitor.Notifications/NotificationRoute.cs","PerformanceMonitor.Notifications/NotificationRouter.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs"],"failed_classes":["Darling.Tests.CaggGroupIndexDropLiveTests","Darling.Tests.CollectorRunTimeRungLiveTests","Darling.Tests.DarlingMcpServerAdminToolsLivePostgresTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.RepoFileAdoptionTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (0)","Darling PG tests (2)","Darling whole-tree guards"]} +{"date":"2026-10-06","head_sha":"87ee3cc71d3f0f725534d5ad638d030489dfa18d","event":"pull_request","changed_files":[".github/workflows/build.yml","Darling/Darling.Tests/DarlingAlertingTests.cs","Darling/Darling.Tests/DarlingCliSealKeyTests.cs","Darling/Darling.Tests/DarlingCliUnusableStoreConnectionTests.cs","Darling/Darling.Tests/DarlingConfigTests.cs","Darling/Darling.Tests/DarlingMcpServerAdminToolsTests.cs","Darling/Darling.Tests/DarlingPasswordKeyRuntimeTests.cs","Darling/Darling.Tests/DarlingPasswordKeyStoreLiveTests.cs","Darling/Darling.Tests/DarlingSecretsDecryptFailureTests.cs","Darling/Darling.Tests/DarlingSecretsOwnedReferenceTests.cs","Darling/Darling.Tests/DarlingWebhookSecretsTests.cs","Darling/Darling.Tests/DiagnosticsBundleSealedTextTests.cs","Darling/Darling.Tests/DiagnosticsBundleWebhookOpenTests.cs","Darling/Darling.Tests/Fixtures/PasswordSeal/linux-self-check-vector.json","Darling/Darling.Tests/GenericWebhookHeadersOriginTests.cs","Darling/Darling.Tests/LegacyPinResolverLiveTests.cs","Darling/Darling.Tests/NotificationRoutesRungTests.cs","Darling/Darling.Tests/OwnedSecretReferenceRefusalTests.cs","Darling/Darling.Tests/PasswordBindingCensusTests.cs","Darling/Darling.Tests/PasswordKeyGrantParityTests.cs","Darling/Darling.Tests/PasswordKeyRungTests.cs","Darling/Darling.Tests/PasswordKeySeamLiveTests.cs","Darling/Darling.Tests/PasswordKeySelfCheckTests.cs","Darling/Darling.Tests/PasswordKeyTablesLiveTests.cs","Darling/Darling.Tests/QueryStatsHourLedgerTests.cs","Darling/Darling.Tests/SealedSecretResolverTests.cs","Darling/Darling.Tests/ServerAddViewerRoleLiveTests.cs","Darling/Darling.Tests/ServerEditCoreTests.cs","Darling/Darling.Tests/ServerEditLiveTests.cs","Darling/Darling.Tests/ServerEditPasswordRuleViewerTests.cs","Darling/Darling.Tests/ServerEditViewerRoleLiveTests.cs","Darling/Darling.Tests/ServerIdentityLockLiveTests.cs","Darling/Darling.Tests/ServerPasswordFromRequestTests.cs","Darling/Darling.Tests/StoreConfigProviderConnectionMatchTests.cs","Darling/Darling.Tests/StoreIsOnThisMachineTests.cs","Darling/Darling.Tests/TestKeyRings.cs","Darling/Darling.Tests/ValidateConfigOldFormatPasswordLiveTests.cs","Darling/Darling.Tests/ViewerControlPlaneStage3bTests.cs","Darling/Darling.Tests/ViewerPasswordKeyTests.cs","Darling/Darling.Tests/ViewerServerManagementTests.cs","Darling/Darling.Tests/ViewerServerSealLiveTests.cs","Darling/Darling.Tests/ViewerSmtpSealTests.cs","Darling/Darling.Tests/ViewerWebhookSealLiveTests.cs","Darling/Darling.Tests/ViewerWebhookSealingTests.cs","Darling/Darling.Tests/WebhookDeliverySnapshotTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCliSealKey.cs","Darling/PerformanceMonitor.Darling.Service/DarlingCommandExecutor.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeyRuntime.cs","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeySelfCheck.cs","Darling/PerformanceMonitor.Darling.Service/DarlingPasswordKeyStore.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingServerConnector.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebhookSecrets.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundle.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundleRunner.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.Edit.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpServerAdminTools.cs","Darling/PerformanceMonitor.Darling.Service/Program.cs","Darling/PerformanceMonitor.Darling.Service/SecretTextGuard.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PasswordBinding.cs","Darling/PerformanceMonitor.Darling.Storage/PasswordKeyTables.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/ServerConnectionRule.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/AddMultipleServersDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/AddServerDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/NotificationRouteEditDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/NotificationRoutesWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/PasswordKeyChangedDialog.xaml","Darling/PerformanceMonitor.Darling.Viewer/PasswordKeyChangedDialog.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerControlPlaneMigration.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Commands.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.MonitoredServers.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.NotificationRoutes.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.PasswordKey.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerPasswordKey.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerPasswordKeyPins.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerMigration.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerSecret.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerSmtpSeal.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerWebhookSealing.cs","Darling/README.md","Darling/compose/docker-compose.yml","Darling/compose/secrets/README.md","Darling/tools/provision-roles.sql","Lite.Tests/CrossAppMcpToolInventoryPinTests.cs","Lite.Tests/GenericWebhookHeadersOriginTests.cs","PerformanceMonitor.Notifications/IAlertSettings.cs","PerformanceMonitor.Notifications/NotificationRoute.cs","PerformanceMonitor.Notifications/NotificationRouter.cs"],"failed_classes":["Darling.Tests.CaggGroupIndexDropLiveTests","Darling.Tests.CollectorRunTimeRungLiveTests","Darling.Tests.DarlingMcpServerAdminToolsLivePostgresTests","Darling.Tests.DocCommentHygieneTests","Darling.Tests.RepoFileAdoptionTests","Darling.Tests.StatementFilterPlanReadsLiveTests"],"failed_jobs":["build","Darling PG tests (1)","Darling PG tests (2)","Darling PG tests (0)","Darling whole-tree guards"]} +{"date":"2026-10-06","head_sha":"29d422f582b2502ccc9fab3afb507d6060aa7d31","event":"pull_request","changed_files":[".github/workflows/build.yml"],"failed_classes":["Darling.Tests.TrendPayloadBudgetLiveTests"],"failed_jobs":["Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"91536b53039d311bbe8fdc5a46fbf09d3b90cdd5","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingMcpPlanCorrectionToolsTests.cs","Darling/Darling.Tests/DarlingMcpTrendToolsTests.cs","Darling/Darling.Tests/DarlingTrendEmptyTests.cs","Darling/Darling.Tests/DatabaseFilterEmptyAnswerLiveTests.cs","Darling/Darling.Tests/DeltaFamilyIntervalCompletionLivePostgresTests.cs","Darling/Darling.Tests/DurationTrendDatabaseFilterLiveTests.cs","Darling/Darling.Tests/EmptyAnswerDatabaseEchoTests.cs","Darling/Darling.Tests/EventWindowedReadsAreBoundedLivePostgresTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpLongQueryTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpPlanCorrectionTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpQueryStoreClutterTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpTrendTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/McpZeroIsAMeasurementTests.cs","Darling/Darling.Tests/PlanCorrectionFrameLiveTests.cs","Darling/Darling.Tests/PlanCorrectionsWebDefaultTests.cs","Darling/Darling.Tests/QueryEventsDatabaseFilterLiveTests.cs","Darling/Darling.Tests/QueryReadsWebDispatchLiveTests.cs","Darling/Darling.Tests/QueryStoreClutterLivePostgresTests.cs","Darling/Darling.Tests/QueryStoreClutterTests.cs","Darling/Darling.Tests/QueryStoreTrendIntervalRateTests.cs","Darling/Darling.Tests/QueryStoreTrendRoutingTests.cs","Darling/Darling.Tests/ViewerBlockingSourceLabelTests.cs","Darling/Darling.Tests/ViewerBlockingSourceLivePostgresTests.cs","Darling/Darling.Tests/ViewerQueriesRestTests.cs","Darling/Darling.Tests/ViewerQueryTrendBucketingReviewTests.cs","Darling/Darling.Tests/ViewerTrendRoutingPortTests.cs","Darling/Darling.Tests/WebBlockingSourceBehaviourTests.cs","Darling/Darling.Tests/WebDataStartNoteTests.cs","Darling/Darling.Tests/WebDatabaseFilterChipsBehaviourTests.cs","Darling/Darling.Tests/web-blocking-source-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingLongQueryReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpLongQueryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpPlanCorrectionTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpQueryStoreClutterTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingPlanCorrectionReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/McpCommandDeadlines.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/database-filter-reads.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Storage/DarlingQueryStoreClutterReader.cs","Darling/PerformanceMonitor.Darling.Storage/DurationTrendRouting.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.BlockingTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStoreClutter.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryTrends.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerServerTab.Blocking.cs","Lite.Tests/BlockingSourceLabelTests.cs","Lite.Tests/DurationTrendDatabaseFilterToolTests.cs","Lite.Tests/DurationTrendFilteredDenominatorTests.cs","Lite.Tests/EventListDatabaseFilterToolTests.cs","Lite.Tests/McpToolsListBudget/McpLongQueryTools.txt","Lite.Tests/McpToolsListBudget/McpPlanCorrectionTools.txt","Lite.Tests/McpToolsListBudget/McpQueryTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/PerformanceTrendDurationBucketingTests.cs","Lite/Controls/ServerTab.BlockingStats.cs","Lite/Controls/ServerTab.Charts.cs","Lite/Mcp/McpLongQueryTools.cs","Lite/Mcp/McpPlanCorrectionTools.cs","Lite/Mcp/McpQueryTools.cs","Lite/Services/LocalDataService.DatabaseFilter.cs","Lite/Services/LocalDataService.LongQueries.cs","Lite/Services/LocalDataService.QueryStats.cs","Lite/Services/LocalDataService.TrendBuckets.cs","PerformanceMonitor.Common/Mcp/McpHelpers.cs","PerformanceMonitor.Ui/BlockingSourceLabel.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (0)","Darling PG tests (1)"]} +{"date":"2026-10-06","head_sha":"a6cfe86fd69ac3131b6f6c4f1d9de768a1f4ed59","event":"pull_request","changed_files":["Darling/Darling.Tests/QueryStoreTopDailyReadLiveTests.cs","Darling/Darling.Tests/ViewerQueriesTests.cs","Darling/Darling.Tests/ViewerTextLookupWindowBoundLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs","Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ProcedureStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStats.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.QueryStore.cs"],"failed_classes":["Darling.Tests.DocCommentHygieneTests"],"failed_jobs":["build","Darling PG tests (1)"]} +{"date":"2026-10-07","head_sha":"64d26b5365e4d000fc6f577d9e61f1a10290f330","event":"pull_request","changed_files":["Darling/Darling.Tests/StoreObjectConvergenceTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs"],"failed_classes":["Darling.Tests.LivePostgresCollectionHygieneTests"],"failed_jobs":["build","Darling PG tests (2)"]} +{"date":"2026-10-07","head_sha":"f796927ad5ea5cb0100325a46599aa24e9853dac","event":"pull_request","changed_files":["Darling/Darling.Tests/CollectionGapAtStartTests.cs","Darling/Darling.Tests/DarlingTriageEndpointTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/SelfAlertFailedSendCensusTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Lite.Tests/AlertSeverityTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/AlertSeverity.cs"],"failed_classes":["Darling.Tests.AlertReadFailureSurfaceTests","Darling.Tests.CollectionGapAtStartTests"],"failed_jobs":["build","Darling PG tests (4)","Darling PG tests (5)"]} +{"date":"2026-10-07","head_sha":"02ae3efb1f409e38b77d30942a23a17073e6b708","event":"pull_request","changed_files":["Darling/Darling.Tests/DarlingPasswordKeyRuntimeTests.cs","Darling/Darling.Tests/DarlingPasswordKeyStoreLiveTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"],"failed_classes":["Darling.Tests.StatementFilterAlertTests"],"failed_jobs":["Darling PG tests (4)"]} +{"date":"2026-10-07","head_sha":"fb215e45381ce3378433dcf434a04f9533068416","event":"push","changed_files":["Darling/Darling.Tests/AlertDeliveryChannelTests.cs","Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs","Darling/Darling.Tests/DarlingSelfAlertTests.cs","Darling/Darling.Tests/IncidentDeliveryFilterTests.cs","Darling/Darling.Tests/PagerDutyAutoResolveRungTests.cs","Darling/Darling.Tests/PasswordKeyRungTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingAlertSettings.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWebhookSecrets.cs","Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs","Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs","Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml","Darling/PerformanceMonitor.Darling.Viewer/SettingsWindow.xaml.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.Notification.cs","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs","Darling/tools/provision-roles.sql","Lite.Tests/AgAlertEvaluatorTests.cs","Lite.Tests/AlertIncidentRenderTests.cs","Lite.Tests/IncidentCooldownTests.cs","Lite.Tests/PagerDutyWebhookTests.cs","Lite.Tests/PerEventNotificationTests.cs","Lite/App.xaml.cs","Lite/Services/AgAlertEvaluator.cs","Lite/Services/AppAlertSettings.cs","Lite/Windows/SettingsWindow.xaml","Lite/Windows/SettingsWindow.xaml.cs","Lite/config/settings.sample.json","PerformanceMonitor.Notifications/AgAlertContexts.cs","PerformanceMonitor.Notifications/AlertContext.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/EmailSendCore.cs","PerformanceMonitor.Notifications/IAlertSettings.cs","PerformanceMonitor.Notifications/IncidentCooldown.cs","PerformanceMonitor.Notifications/IncidentDeliveryFilter.cs","PerformanceMonitor.Notifications/PerEventNotification.cs","PerformanceMonitor.Notifications/WebhookAlertService.cs"],"failed_classes":["PerformanceMonitorLite.Tests.BlockingDatabaseFilterToolTests"],"failed_jobs":["Lite tests (2)"]} +{"date":"2026-10-07","head_sha":"0f92fa3b30bd7349208a84cf2649d4dd9aee981d","event":"push","changed_files":["Darling/Darling.Tests/DarlingHeartbeatTests.cs","Darling/PerformanceMonitor.Darling.Service/DarlingConfig.cs","Darling/PerformanceMonitor.Darling.Service/DarlingHeartbeat.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/DiagnosticsBundle.cs","Darling/PerformanceMonitor.Darling.Service/darling.sample.json","Darling/README.md"],"failed_classes":["Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests"],"failed_jobs":["Darling PG tests (3)"]} +{"date":"2026-10-07","head_sha":"61afbdfd924262bba990d222be66b2146164c8c0","event":"pull_request","changed_files":["Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs","Darling/Darling.Tests/CollectionHistoryAuditTests.cs","Darling/Darling.Tests/DarlingTriageEndpointTests.cs","Darling/Darling.Tests/NotificationRoutingTests.cs","Darling/Darling.Tests/SelfAlertFailedSendCensusTests.cs","Darling/PerformanceMonitor.Darling.Service/CollectionHistoryAudit.cs","Darling/PerformanceMonitor.Darling.Service/DarlingSelfAlertEvaluator.cs","Darling/PerformanceMonitor.Darling.Service/DarlingTriageEndpoint.cs","Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs","Darling/PerformanceMonitor.Darling.Service/SelfAlertDeliveryStampStore.cs","Darling/README.md","Lite.Tests/AlertSeverityTests.cs","PerformanceMonitor.Common/AlertMetricClassifier.cs","PerformanceMonitor.Notifications/AlertFamily.cs","PerformanceMonitor.Notifications/AlertSeverity.cs"],"failed_classes":["Darling.Tests.AlertMasterSwitchSurfaceTests","Lite.Tests.CrossSkuSurfaceSourceTests"],"failed_jobs":["build","Lite tests (2)","Darling PG tests (1)","Darling PG tests (4)"]} +{"date":"2026-10-07","head_sha":"ff2bd1569e4c8da726367c34525d9ac0c0d1aaff","event":"push","changed_files":["Darling/Darling.Tests/ConfigDatabaseFilterLiveTests.cs","Darling/Darling.Tests/DarlingMcpConfigHistoryToolsTests.cs","Darling/Darling.Tests/DarlingMcpDefaultTraceToolsTests.cs","Darling/Darling.Tests/DarlingMcpHealthParserToolsTests.cs","Darling/Darling.Tests/DatabaseConfigWebColumnsTests.cs","Darling/Darling.Tests/DatabaseNameHistoryTests.cs","Darling/Darling.Tests/EventsDatabaseFilterLiveTests.cs","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpConfigHistoryTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpDefaultTraceTools.txt","Darling/Darling.Tests/McpToolsListBudget/DarlingMcpHealthParserTools.txt","Darling/Darling.Tests/McpToolsListBudgetTests.cs","Darling/Darling.Tests/WebDatabaseFilterChipsBehaviourTests.cs","Darling/Darling.Tests/WebDatabaseFilterReadsTests.cs","Darling/Darling.Tests/WebReadProbe.cs","Darling/Darling.Tests/web-database-filter-chips-harness.mjs","Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingConfigHistoryReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingCurrentConfigReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDefaultTraceReader.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigHistoryTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpConfigTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDefaultTraceTools.cs","Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpHealthParserTools.cs","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/database-filter-reads.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js","Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js","Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.SystemEvents.cs","Lite.Tests/McpDatabaseNameFilterToolTests.cs","Lite.Tests/McpToolsListBudget/McpConfigHistoryTools.txt","Lite.Tests/McpToolsListBudget/McpDefaultTraceTools.txt","Lite.Tests/McpToolsListBudget/McpHealthParserTools.txt","Lite.Tests/McpToolsListBudgetTests.cs","Lite.Tests/SystemEventsReaderTests.cs","Lite/Mcp/McpConfigHistoryTools.cs","Lite/Mcp/McpConfigTools.cs","Lite/Mcp/McpDefaultTraceTools.cs","Lite/Mcp/McpHealthParserTools.cs","Lite/Services/LocalDataService.SystemEvents.cs","PerformanceMonitor.Common/DatabaseNameHistory.cs"],"failed_classes":["Darling.Tests.StatementFilterBlockingReadsLiveTests"],"failed_jobs":["Darling PG tests (2)"]} diff --git a/.github/ci-history/replay-accepted.txt b/.github/ci-history/replay-accepted.txt new file mode 100644 index 0000000000..412eee2bef --- /dev/null +++ b/.github/ci-history/replay-accepted.txt @@ -0,0 +1,129 @@ +# Corpus rows today's rules do not select a failing class for, and why that is not a selection gap. +# A miss not listed here fails ci-select.py --replay. Format: <10-char head sha> . + +# A Darling class failed on a run whose changed files (as last recorded for the pull request) are +# documentation only. The failing attempt ran on an earlier head of the pull request that held code, so the +# recorded list is not the list that run saw. +cc29d98125 Darling.Tests.RepoFileAdoptionTests +7b64070c98 Darling.Tests.DarlingDeployRollbackRetentionTests +e6dbdbea94 Darling.Tests.IncidentAttachmentDeliveryTests + +# A Lite class failed on a Darling-only diff, before the Reads=Darling selection (build.yml, 2026-10-04) +# existed or while the pull request still carried the older workflow. DarlingReadsTraitGuardTests derives the +# Reads=Darling tag from the class source, so a class without it reads no Darling file: the diff did not cause +# the failure. +d0d482835c PerformanceMonitorLite.Tests.PerformanceCalendarDataTests +cd34bbcb31 PerformanceMonitorLite.Tests.StatusBarSizeReadLockTests +1792370ebd PerformanceMonitorLite.Tests.McpPageContractTests +32239fd349 PerformanceMonitorLite.Tests.MemoryTrendGrantJoinToolTests +ad89ad83a7 PerformanceMonitorLite.Tests.LocalClockBucketKeyTests +08ca596e65 PerformanceMonitorLite.Tests.LocalClockBucketKeyTests +d064b8ebc5 PerformanceMonitorLite.Tests.LocalClockBucketKeyTests +1e9c8ce764 PerformanceMonitorLite.Tests.LocalClockBucketKeyTests +95a42dab2d PerformanceMonitorLite.Tests.LocalClockBucketKeyTests +5219f29479 PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests +e4cb4a3699 PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests +1c726d7f67 PerformanceMonitorLite.Tests.AnomalyDetectorTileBehaviourTests +c0adc53226 PerformanceMonitorLite.Tests.ScenarioTests +68ddf17466 PerformanceMonitorLite.Tests.ScenarioTests +a75333e8e2 PerformanceMonitorLite.Tests.ScenarioTests +801cb7c1d8 PerformanceMonitorLite.Tests.ScenarioTests +63ba0d2893 PerformanceMonitorLite.Tests.DuckDbSentinelConnectionTests +411d87afbe PerformanceMonitorLite.Tests.FinOpsTests +eca647c2f4 PerformanceMonitorLite.Tests.QueryStoreRegressionsToolTests +eca647c2f4 PerformanceMonitorLite.Tests.SignificantWaitsToolTests +# A Darling class failed on a pull request whose recorded changes are Lite files only (cut 3a/3c of #5459). The +# class source names no Lite path (LiteReadsTraitGuardTests derives the Reads=Lite tag from it, and these carry +# none; the one 'Lite' in PlanSync4512CancellationTests is a doc comment), so no Lite edit can reach what it +# tests: each is a timing or live-store test that failed on a first attempt, not a selection gap. +c3a18e1785 Darling.Tests.PgTargetWaitTests +67bf55c45a Darling.Tests.ClipboardTextRetryBudgetTests +48216212d0 Darling.Tests.PlanSync4512CancellationTests + +# Map back-test (ci-select.py --replay --map, #5459): a class failed on a run whose changed files do not reach it, +# and the failure is not a selection gap. Each class was checked against the corpus: none of the run's changed +# product files is named by the class, none is covered by it, and the class also failed on runs with a different +# change set, on a push to dev, or as the only failing class of one shard job (a flake or an environment fault). +# The two mass failures of 2026-09-26 and 2026-09-27 (a store migration broke the shared live-store fixture) are not +# here: the map builder now links every class of a big xunit collection to what the collection's fixture ran. +02ae3efb1f Darling.Tests.StatementFilterAlertTests +03d9905807 Darling.Tests.PgServerLogTailRotationLiveTests +0654199a72 Darling.Tests.PgServerLogTailRotationLiveTests +0ac14fc78e Darling.Tests.DarlingAnomalyBaselineTests +0b92d8248f Darling.Tests.PgServerLogTailRotationLiveTests +0c58c0eb56 Darling.Tests.PgTargetSampledWaitLiveTests +0c983ef54d Darling.Tests.PgLogBurstAndRotationLiveTests +11729239b1 Darling.Tests.XminHorizonPersistenceReadTests +2c39e009ff Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +33932296ca Darling.Tests.StoreStatementStatsLiveTests +3af3ade9ea Darling.Tests.StoreStatementStatsLiveTests +3b2d41baae Darling.Tests.QueryStoreIntervalWideGridLiveTests +3bb3ae8fe9 Darling.Tests.TrendPayloadBudgetLiveTests +3de285b652 Darling.Tests.DarlingAnomalyBaselineTests +40284b0061 Darling.Tests.PayloadDimensionLiveTests +4756659045 Darling.Tests.QueryStoreLegacyRowIndexLiveTests +4a3f52389e PerformanceMonitorLite.Tests.TimeHonestyRungReadTests +4f8499a944 PerformanceMonitorLite.Tests.TimeHonestyRungReadTests +6ac72f8605 Darling.Tests.StoreStatementStatsLiveTests +6de10f4dcb Darling.Tests.StoreSessionTimeZoneLiveTests +87836cc97e Darling.Tests.DarlingAnomalyBaselineTests +87e78aa474 PerformanceMonitorLite.Tests.DataStartBannerProbeFailureTests +88204f7cd2 Darling.Tests.RdsDeadlockIngestorTests +8875188337 Darling.Tests.DarlingAnomalyBaselineTests +89c271dd17 Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +8a800ed6c6 PerformanceMonitorLite.Tests.LiteDeltaSeederTests +8b3c5767f0 Darling.Tests.SqlServerStoreTileBehaviourTests +8f95de197e Darling.Tests.StatementFilterAlertTests +93d34a73cd Darling.Tests.RollupBackfillLiveTests +99ad6aebf6 Darling.Tests.PlanSync4512CancellationTests +9be59f1a95 Darling.Tests.CollectionLogDrainForensicsStoreTests +a3b5749937 Darling.Tests.DarlingAnomalyBaselineTests +af31e98db0 Darling.Tests.StoreSessionTimeZoneLiveTests +b33399f219 Darling.Tests.DarlingCloudIdentityProbeTests +c20797c24a Darling.Tests.PgDeadlockNormalizationTests +c9877aad46 Darling.Tests.DarlingAnomalyBaselineTests +c9a1a81456 Darling.Tests.QueryStoreTopLiteralEndStraddleLiveTests +d0d482835c Darling.Tests.XminHorizonPersistenceReadTests +d0fb0ff183 Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +d5370df470 Darling.Tests.DarlingAnomalyBaselineTests +d6c02bf28a Darling.Tests.DarlingStoreUpgradeRevertTests +ed114b5b17 Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +f07bfc441f PerformanceMonitorLite.Tests.TrendEmptyParityToolTests + +# The changelog splice (release archive) run whose failing class is a live-database class that no changelog +# change can affect. Since #5614 the three splice files (CHANGELOG.md, docs/changelog/.md and +# tools/changelog/archive-census.txt) are documentation, so a splice takes the docs fast path and selects no +# class; the failure on that run was not caused by the splice. +e20c881360 Darling.Tests.MigrationUpgradeLadderLiveTests + +# Map replay against the first nightly map (#5459, run 37892110132): six misses left after the map's own links, each +# checked against the failing job's log (measured) and the corpus. None is a selection gap; none is listed to hide +# one. A sha here is the head of a pull request run, and the map, if it were live, would have given that run no +# reason to run the class because the class is unrelated to the diff. +# +# LiteServerTagsStoreTests (a304350fe3, measured): the diff is the Lite logger (AppLogger, App.xaml.cs, the +# collector runner) and the failure is a TimeoutException from LocalDataService.OpenWriteConnectionAsync, "Could not +# acquire database write lock", on the class's first write and then its second. Nothing in the diff opens or holds +# a store write lock. Every later head of the same pull request (9981020a8a, 54244d4282, f415eae60c) passed, with +# the logger files still in its diff, and no other row of the corpus has this class failing. A shared-fixture lock +# timeout on a loaded runner (the lock holder is inferred, not seen). +a304350fe3 Lite.Tests.LiteServerTagsStoreTests +# +# EventWindowedReadsAreBoundedLivePostgresTests (the four heads of one pull request on 2026-09-25, measured): the +# test had been failing on dev itself from the merge of #4235 (06:11Z) until its fix, #4275 (10:34Z): the corpus +# holds it failing on two dev pushes (f5e001fe65, 659e11a39b) and on nine other pull-request heads that day, all +# changing other things, nearly all with "Darling PG tests (0)" as the failed job. The four heads ran 09:33Z to +# 10:14Z, inside that window; the first one's log shows the plan test's own message ("expected the floor to exclude at +# least 2 of the 4 seeded old-day chunks"). The change the four heads carry (how the store host is set up) does not +# explain the failure: the failure predates it. +f1a1894fd8 Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +69ce7ff7be Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +4b3fe51a97 Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +3747867b5a Darling.Tests.EventWindowedReadsAreBoundedLivePostgresTests +# +# StoreMetricsLatestSkipScanLiveTests (3bb3ae8fe9, measured): attempt 1 of the run failed in "Darling PG tests (2)" +# only, five classes within one second (06:52:04 to 06:52:05), with "57P03: the database system is in recovery +# mode" (the shard's PostgreSQL server had restarted after a backend crash) and the scratch-database drops retrying +# on the same error. Attempt 2 of the same head passed every shard. A first-attempt environment fault of the +# shard's server; the 28 changed files (viewer data-start work) did not cause it. +3bb3ae8fe9 Darling.Tests.StoreMetricsLatestSkipScanLiveTests diff --git a/.github/darling-paths-filter.yml b/.github/darling-paths-filter.yml index 4009f8a810..d2870c19e5 100644 --- a/.github/darling-paths-filter.yml +++ b/.github/darling-paths-filter.yml @@ -1,6 +1,8 @@ # The ONE Darling path gate. Both Darling jobs in .github/workflows/build.yml — `Darling PostgreSQL # tests` and `Darling Linux build` — read this file through dorny/paths-filter's documented -# `filters: ` form, so the two jobs cannot disagree about what reaches Darling. +# `filters: ` form, so the two jobs cannot disagree about what reaches Darling. The build +# job reads it a third time to learn whether the PostgreSQL shards run (#5459 cut 3b), and +# .github/scripts/ci-select.py reads it to replay the failure corpus. # # A FILE rather than a list per job (#3116). The PG job's own filter named `Darling/**` and the two # workflows and nothing else, while the Linux job in the same file already listed shared libraries; @@ -44,6 +46,15 @@ darling: # worker-sizing formula, so an edit to it has to reach the suite. The Linux job runs no tests and # carries this entry only by sharing the file; it costs that job one publish and one image build. - '.github/workflows/nightly.yml' + # #5616: the Darling PostgreSQL shard script and the Lite shard script are run by the shard jobs and + # parsed by Darling.Tests pins, so an edit to either has to reach the suite. + - '.github/scripts/run-darling-pg-shard.ps1' + - '.github/scripts/run-lite-shard.ps1' + # #5627: the Darling upgrade-shape workflow and its script. The workflow runs the install and upgrade scripts under + # Darling/tools (already covered above) over real releases, and Darling.Tests parses both files, so an edit to + # either has to reach the suite. + - '.github/workflows/darling-upgrade-shapes.yml' + - '.github/scripts/darling-upgrade-shapes.ps1' # Lite/** is deliberately ABSENT, though build.yml's own `darling` filter carries it. That entry # exists because Darling.Tests reads Lite source in its cross-app parity guards, and those guards run diff --git a/.github/scripts/altcover-probe.py b/.github/scripts/altcover-probe.py new file mode 100644 index 0000000000..9c7dab0e23 --- /dev/null +++ b/.github/scripts/altcover-probe.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +"""AltCover per-test coverage probe helper (#5459 change 4, slice 2). Standard library only. + +The nightly workflow's dispatch-only `altcover-probe` job uses three subcommands: + + select Pick test classes from a runner class listing (`-list classes/json`), spread over kinds that + come from the class's source text. Profile `darling`: plain unit classes, TestHost / web classes + (a TestServer / WebApplicationFactory mention) and live PostgreSQL classes (a DARLING_TEST_PG + mention). Profile `lite`: plain classes, DuckDB-backed classes and classes that start collectors + (a RemoteCollectorService mention). Stage=Guard classes are skipped because they read source text + and never run product code. Deterministic: the sorted candidates are sampled at even steps. + + unwrap-skips An instrumented run reports a dynamic skip as an AggregateException failure (see the function). + This rewrites the instrumented xunit report so those tests are skips again, but only for tests + the plain run also skipped. + + summarize Turn an AltCover OpenCover report (`--callContext=[Fact] --callContext=[Theory]`) into + (a) a summary of the probe: overhead, share of visits that carry a test context, holes, and + (b) the schema-1 `classes` section of the test map the plan describes: + {schema, tool, files:[universe], classes:{suite:{Class:{own, files:[ids], seconds}}}} + `own` is the repo-relative file that declares the class; `files` are indexes into the universe + of product source files the class's tests visited; `seconds` is the class's summed test time. + +Both subcommands only read files; nothing here talks to a server. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import statistics +import sys +import xml.etree.ElementTree as ET + +SCHEMA = 1 +CLASS_DECL = re.compile(r"\bclass\s+([A-Za-z_][A-Za-z0-9_]*)") +WEB_MARKERS = ("WebApplicationFactory", "UseTestServer", "TestServer", "Microsoft.AspNetCore.TestHost") +LIVE_MARKER = "DARLING_TEST_PG" +RUNTIME_MARKER = "DARLING_TEST_PGRUNTIME" +LITE_COLLECTOR_MARKERS = ("RemoteCollectorService",) +LITE_DUCKDB_MARKERS = ("DuckDB", "DuckDb", "LocalDataService") +# Default per-kind sample counts (the rest of --total is plain). +PROFILE_WANTS = {"darling": {"web": 25, "live": 12}, "lite": {"collector": 8, "duckdb": 20}} +SKIP_TOKEN = "$XunitDynamicSkip$" +TEXT_READ = re.compile(r"File\.ReadAll|RepoRoot|ReadAllText|ReadLines\(") +REFLECTION = re.compile(r"GetCustomAttribute|GetMethod\(|GetTypes\(|GetProperties\(|GetFields\(|typeof\(") +GUARD_MARKER = re.compile(r"Trait\(\s*\"Stage\"\s*,\s*\"Guard\"\s*\)") + + +def norm(path: str) -> str: + return path.replace("\\", "/") + + +def simple_name(full: str) -> str: + return re.split(r"[.+/]", full)[-1] + + +def classify(text, profile): + if GUARD_MARKER.search(text): + return "guard" + if profile == "lite": + if any(marker in text for marker in LITE_COLLECTOR_MARKERS): + return "collector" + if any(marker in text for marker in LITE_DUCKDB_MARKERS): + return "duckdb" + return "plain" + if any(marker in text for marker in WEB_MARKERS): + return "web" + if LIVE_MARKER in text and RUNTIME_MARKER not in text: + return "live" + return "plain" + + +def scan_sources(dirs, root, profile="darling"): + """Map simple class name -> {file, kind} for every test source file under `dirs`.""" + found = {} + for base in dirs: + for folder, subdirs, names in os.walk(base): + subdirs[:] = [d for d in subdirs if d not in ("bin", "obj", ".git", "node_modules")] + for name in names: + if not name.endswith(".cs"): + continue + path = os.path.join(folder, name) + try: + with open(path, encoding="utf-8-sig", errors="replace") as handle: + text = handle.read() + except OSError: + continue + kind = classify(text, profile) + rel = norm(os.path.relpath(path, root)) + for cls in CLASS_DECL.findall(text): + found.setdefault(cls, {"file": rel, "kind": kind}) + return found + + +def spread(names, count): + names = sorted(names) + if count <= 0 or not names: + return [] + if count >= len(names): + return names + step = len(names) / count + return [names[int(i * step)] for i in range(count)] + + +def cmd_select(args): + with open(args.list, encoding="utf-8-sig") as handle: + text = handle.read() + start = text.index("[") + listed = json.loads(text[start:text.rindex("]") + 1]) + sources = scan_sources(args.tests_dir, args.root, args.profile) + wants = dict(PROFILE_WANTS[args.profile]) + for item in args.want: + name, _, number = item.partition("=") + wants[name] = int(number) + buckets = {"plain": []} + buckets.update({kind: [] for kind in wants}) + for full in listed: + info = sources.get(simple_name(full)) + if info and info["kind"] in buckets: + buckets[info["kind"]].append(full) + counts = {kind: min(number, len(buckets[kind])) for kind, number in wants.items()} + counts["plain"] = max(args.total - sum(counts.values()), 0) + chosen = {kind: spread(buckets[kind], counts[kind]) for kind in buckets} + manifest = {} + lines = [] + for kind, names in chosen.items(): + for full in names: + manifest[full] = {"kind": kind, "file": sources[simple_name(full)]["file"]} + lines.append(full) + with open(args.out, "w", encoding="utf-8", newline="\n") as handle: + handle.write("\n".join(lines) + "\n") + with open(args.manifest, "w", encoding="utf-8", newline="\n") as handle: + json.dump(manifest, handle, indent=1, sort_keys=True) + print("%s: listed %d classes; candidates %s; chose %s (%d total)" % ( + args.profile, len(listed), " ".join("%s=%d" % (k, len(v)) for k, v in sorted(buckets.items())), + " ".join("%s=%d" % (k, len(v)) for k, v in sorted(chosen.items())), len(lines))) + return 0 if lines else 1 + + +def failure_message(test): + failure = test.find("failure") + if failure is None: + return "" + message = failure.find("message") + return (message.text or "") if message is not None else "" + + +def cmd_unwrap_skips(args): + """Turn an instrumented run's wrapped dynamic skips back into skips. + + AltCover rewrites an instrumented async [Fact] so that the method waits on its own task (the failure's stack + is `Task.Wait` called from the test method itself). A dynamic skip (`Assert.Skip*`) thrown inside the async + body therefore surfaces as an AggregateException whose message does not start with the skip token, and + xunit v3 reports a failure instead of a skip. The wrap is in the rewritten test method, not in product or test + source, and 760 Darling test files use dynamic skips, so the report is repaired here instead. + + Guards: a test is converted only when its failure text carries the skip token AND the plain run of the same + test was a skip. A test the plain run did not skip, or any other failure, stays a failure. Nothing is + ever converted to a pass. The original report is kept next to the result as `.raw.xml`. + + Without `--plain` (the map producer runs every class once, instrumented, and has no plain run to compare with) + a failure that carries the skip token is converted on the token alone. Only `Assert.Skip*` writes that token, + and the producer reads coverage from the run, not the pass/fail counts, so this cannot hide a real failure.""" + plain_skips = None # None: no plain run to compare with (the map producer), so the skip token alone decides + if args.plain: + plain_skips = set() + for _, el in ET.iterparse(args.plain, events=("end",)): + if el.tag == "test": + if (el.get("result") or "").lower() == "skip": + plain_skips.add(el.get("name")) + el.clear() + tree = ET.parse(args.instr) + converted = [] + kept = [] + for test in tree.getroot().iter("test"): + if (test.get("result") or "").lower() != "fail": + continue + message = failure_message(test) + if SKIP_TOKEN not in message: + continue + if plain_skips is not None and test.get("name") not in plain_skips: + kept.append(test.get("name")) + continue + reason = message[message.index(SKIP_TOKEN) + len(SKIP_TOKEN):].split("\n", 1)[0].strip().rstrip(")") + failure = test.find("failure") + if failure is not None: + test.remove(failure) + test.set("result", "Skip") + ET.SubElement(test, "reason").text = reason + converted.append(test.get("name")) + if converted: + # Counts live on the collection and assembly elements; move each converted test from failed to skipped. + moved_names = set(converted) + for parent in tree.getroot().iter(): + if parent.tag not in ("collection", "assembly"): + continue + moved = sum(1 for test in parent.iter("test") if test.get("name") in moved_names) + if moved and parent.get("failed") is not None and parent.get("skipped") is not None: + parent.set("failed", str(max(int(parent.get("failed")) - moved, 0))) + parent.set("skipped", str(int(parent.get("skipped")) + moved)) + raw = os.path.splitext(args.instr)[0] + ".raw.xml" + if not os.path.exists(raw): + os.replace(args.instr, raw) + tree.write(args.instr, encoding="utf-8", xml_declaration=True) + print("unwrap-skips: %d converted back to skips, %d left failing (skip token, but the plain run did not skip)" % ( + len(converted), len(kept))) + for name in converted[:12]: + print(" skip restored: " + name) + for name in kept[:12]: + print(" still failing: " + name) + return 0 + + +def class_of_tracked(name: str) -> str: + """`System.Void Ns.Outer/Inner::Method(System.String)` -> `Ns.Outer+Inner` (the declaring type).""" + left = name.split("::", 1)[0] + return left.split(" ")[-1].replace("/", "+") + + +def parse_report(path, root): + """Stream an OpenCover report. Returns (context -> set(files), file stats, totals).""" + root_norm = norm(root).rstrip("/").lower() + "/" + ctx_files: dict[str, set] = {} + file_stats: dict[str, list] = {} # file -> [visits, visits with a context] + totals = {"modules": 0, "methods": 0, "points": 0, "points_visited": 0, "points_visited_with_context": 0, + "visits": 0, "visits_with_context": 0, "tracked_methods": 0, "tracked_refs": 0, "tracked_uid_collisions": 0, "unresolved_refs": 0, "points_partly_outside_context": 0, "points_fully_in_context": 0, "context_refs_on_visited_points": 0, + "ref_owner": {}} + stack = [] + names: dict[str, str] = {} + resolved: set = set() + files = {} + pairs = set() + mod_stats: dict = {} # file uid -> [visits, visits with a context], per module (uids repeat across modules) + method_file = None + point = None # [file, vc, ref_vc, ref_count] + + def finish_point(): + nonlocal point + if point is None: + return + file, vc, ref_vc, ref_count = point + totals["points"] += 1 + if vc > 0: + totals["points_visited"] += 1 + totals["visits"] += vc + totals["visits_with_context"] += min(ref_vc, vc) if ref_count else 0 + if ref_count: + totals["points_visited_with_context"] += 1 + # A point whose per-context visit counts add up to less than its total was also reached + # outside every context (a constructor, a fixture, a pool thread started by the test). + totals["points_partly_outside_context" if ref_vc < vc else "points_fully_in_context"] += 1 + totals["context_refs_on_visited_points"] += ref_count + stat = mod_stats.setdefault(file, [0, 0]) + stat[0] += vc + stat[1] += min(ref_vc, vc) if ref_count else 0 + point = None + + for event, el in ET.iterparse(path, events=("start", "end")): + tag = el.tag + if event == "start": + stack.append(tag) + if tag == "Module": + totals["modules"] += 1 + files = {} + pairs = set() + mod_stats = {} + elif tag == "File": + files[el.get("uid")] = norm(el.get("fullPath") or "") + elif tag == "Method": + totals["methods"] += 1 + method_file = None + elif tag == "FileRef" and "Method" in stack: + method_file = el.get("uid") + elif tag in ("SequencePoint", "MethodPoint"): + finish_point() + point = [el.get("fileid") or method_file, int(el.get("vc") or 0), 0, 0] + elif tag == "TrackedMethodRef": + uid = el.get("uid") + vc = int(el.get("vc") or 0) + owner = stack[-3] if len(stack) >= 3 else "?" + totals["tracked_refs"] += 1 + totals["ref_owner"][owner] = totals["ref_owner"].get(owner, 0) + 1 + file = (point[0] if point is not None else None) or method_file + if point is not None: + point[2] += vc + point[3] += 1 + pairs.add((uid, file)) + else: + if tag in ("SequencePoint", "MethodPoint"): + finish_point() + elif tag == "Method": + finish_point() + el.clear() + elif tag == "TrackedMethod": + totals["tracked_methods"] += 1 + elif tag == "TrackedMethods": + pass + elif tag == "Module": + for fuid, (visit_count, ctx_count) in mod_stats.items(): + if fuid in files: + stat = file_stats.setdefault(files[fuid], [0, 0]) + stat[0] += visit_count + stat[1] += ctx_count + # TrackedMethod uids are numbered across the whole report, and a product module's points + # refer to test methods listed in the TEST module, so names are resolved after the last + # module rather than inside the module that holds the reference. + for tm in el.iter("TrackedMethod"): + uid, name = tm.get("uid"), tm.get("name") or "" + if uid in names and names[uid] != name: + totals["tracked_uid_collisions"] += 1 + names[uid] = name + for uid, fuid in pairs: + if fuid in files: + resolved.add((uid, files[fuid])) + el.clear() + stack.pop() + for uid, full in resolved: + name = names.get(uid) + if not name: + totals["unresolved_refs"] += 1 + continue + if full.lower().startswith(root_norm): + ctx_files.setdefault(class_of_tracked(name), set()).add(full[len(root_norm):]) + return ctx_files, file_stats, totals, root_norm + + +def xunit_times(path): + """Per-class seconds and outcome counts from an xunit `-xml` report (v2 and v3 shapes).""" + seconds: dict[str, float] = {} + counts = {"pass": 0, "fail": 0, "skip": 0} + if not path or not os.path.exists(path): + return seconds, counts + for _, el in ET.iterparse(path, events=("end",)): + if el.tag == "test": + cls = (el.get("type") or "").replace("/", "+") + try: + seconds[cls] = seconds.get(cls, 0.0) + float(el.get("time") or 0) + except ValueError: + pass + result = (el.get("result") or "").lower() + counts["pass" if result == "pass" else "fail" if result == "fail" else "skip"] += 1 + el.clear() + return seconds, counts + + +def hole_detail(info, visited, root): + """Why a class has no product file: what it did visit, and what its own source text suggests.""" + detail = {"kind": info.get("kind"), "test_files_visited": sorted(visited)[:6]} + path = os.path.join(root, info.get("file") or "") + try: + with open(path, encoding="utf-8-sig", errors="replace") as handle: + text = handle.read() + except OSError: + return detail + detail["reads_source_text"] = bool(TEXT_READ.search(text)) + detail["uses_reflection_or_typeof"] = bool(REFLECTION.search(text)) + detail["needs_pg_runtime"] = RUNTIME_MARKER in text + return detail + + +def cmd_summarize(args): + ctx_files, file_stats, totals, root_prefix = parse_report(args.report, args.root) + with open(args.manifest, encoding="utf-8") as handle: + manifest = json.load(handle) + plain_seconds, plain_counts = xunit_times(args.plain_xml) + instr_seconds, instr_counts = xunit_times(args.instr_xml) + timing = {} + if args.timing and os.path.exists(args.timing): + with open(args.timing, encoding="utf-8-sig") as handle: + timing = json.load(handle) + + test_prefixes = tuple(p.strip("/") + "/" for p in args.test_dir_prefix) + + def is_test_file(rel): + return rel.startswith(test_prefixes) + + # Product-file universe and per-class rows. + universe = sorted({f for fs in ctx_files.values() for f in fs if not is_test_file(f)}) + index = {f: i for i, f in enumerate(universe)} + selected = {cls.replace("/", "+"): info for cls, info in manifest.items()} + classes = {} + holes = {"no_context": [], "no_product_files": [], "detail": {}} + per_kind = {} + for cls, info in sorted(selected.items()): + got = ctx_files.get(cls) + if got is None: + holes["no_context"].append(cls) + continue + product = sorted(index[f] for f in got if f in index) + if not product: + holes["no_product_files"].append(cls) + holes["detail"][cls] = hole_detail(info, got, args.root) + classes[cls] = {"own": info.get("file"), "files": product, + "seconds": round(instr_seconds.get(cls, plain_seconds.get(cls, 0.0)), 3)} + per_kind.setdefault(info["kind"], []).append(len(product)) + unselected = sorted(c for c in ctx_files if c not in selected) + + def stat(values): + if not values: + return None + return {"n": len(values), "min": min(values), "median": statistics.median(values), "max": max(values)} + + # Visits to product files only: test-method bodies are always inside a context by construction, so the + # overall share flatters the tool. `outside_root` is source the checkout does not hold (generated code). + visits = with_ctx = test_visits = outside_visits = 0 + for path, (visit_count, ctx_count) in file_stats.items(): + if not path.lower().startswith(root_prefix): + outside_visits += visit_count + elif is_test_file(path[len(root_prefix):]): + test_visits += visit_count + else: + visits += visit_count + with_ctx += ctx_count + weakest = sorted( + ((f, v, c) for f, (v, c) in file_stats.items() + if f.lower().startswith(root_prefix) and v >= 5 and not is_test_file(f[len(root_prefix):])), + key=lambda row: (row[2] / row[1], -row[1]))[:25] + # File-level view, the one the map cares about: a product file the run visited but never attributed + # to any test class cannot select a single class. + product_files = {f[len(root_prefix):]: v for f, (v, c) in file_stats.items() + if f.lower().startswith(root_prefix) and not is_test_file(f[len(root_prefix):]) and v > 0} + attributed = set(universe) + unattributed = sorted(((v, f) for f, v in product_files.items() if f not in attributed), reverse=True) + plain_total = timing.get("plain_seconds") + instr_total = timing.get("instrumented_seconds") + summary = { + "schema": SCHEMA, + "tool": args.tool, + "classes_selected": len(selected), + "classes_with_files": len(classes) - len(holes["no_product_files"]), + "holes": holes, + "contexts_for_unselected_classes": unselected[:50], + "files_in_universe": len(universe), + "files_per_class": {kind: stat(vals) for kind, vals in sorted(per_kind.items())}, + "visits": { + "points_in_report": totals["points"], "points_visited": totals["points_visited"], + "points_visited_with_context": totals["points_visited_with_context"], + "product_visits": visits, "product_visits_with_context": with_ctx, + "share_with_context": round(with_ctx / visits, 4) if visits else None, + "test_file_visits": test_visits, "outside_root_visits": outside_visits, + "points_partly_outside_context": totals["points_partly_outside_context"], + "points_fully_in_context": totals["points_fully_in_context"], + "contexts_per_context_bearing_point": round( + totals["context_refs_on_visited_points"] / totals["points_visited_with_context"], 2) + if totals["points_visited_with_context"] else None, + "unresolved_refs": totals["unresolved_refs"], "tracked_uid_collisions": totals["tracked_uid_collisions"], + "tracked_methods": totals["tracked_methods"], "tracked_refs": totals["tracked_refs"], + "tracked_ref_owner_tags": totals["ref_owner"], + }, + "files_visited_by_any_code": len(product_files), + "files_attributed_to_a_class": len(attributed & set(product_files)), + "files_visited_never_attributed": len(unattributed), + "never_attributed_top": [{"file": f, "visits": v} for v, f in unattributed[:40]], + "weakest_context_files": [{"file": f[len(root_prefix):], "visits": v, "with_context": c} + for f, v, c in weakest], + "timing_seconds": {"plain": plain_total, "instrumented": instr_total, + "ratio": round(instr_total / plain_total, 2) if plain_total and instr_total else None, + "instrument_step": timing.get("instrument_seconds"), + "line_and_branch_mode": {k: v for k, v in timing.items() if k.startswith("line_")}, + "method_mode_report_bytes": os.path.getsize(args.report), + "xunit_plain_sum": round(sum(plain_seconds.values()), 1), + "xunit_instrumented_sum": round(sum(instr_seconds.values()), 1)}, + "outcomes": {"plain": plain_counts, "instrumented": instr_counts}, + } + with open(args.summary_out, "w", encoding="utf-8", newline="\n") as handle: + json.dump(summary, handle, indent=1) + suite = args.suite + with open(args.map_out, "w", encoding="utf-8", newline="\n") as handle: + json.dump({"schema": SCHEMA, "tool": args.tool, "files": universe, "classes": {suite: classes}}, + handle, separators=(",", ":")) + print(json.dumps(summary, indent=1)[:6000]) + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + sub = parser.add_subparsers(dest="command", required=True) + sel = sub.add_parser("select") + sel.add_argument("--profile", choices=sorted(PROFILE_WANTS), default="darling") + sel.add_argument("--want", action="append", default=[], help="kind=count, overrides the profile default") + sel.add_argument("--list", required=True) + sel.add_argument("--tests-dir", action="append", required=True) + sel.add_argument("--root", required=True) + sel.add_argument("--total", type=int, default=100) + sel.add_argument("--out", required=True) + sel.add_argument("--manifest", required=True) + sel.set_defaults(func=cmd_select) + unwrap = sub.add_parser("unwrap-skips") + unwrap.add_argument("--plain", help="the plain run's xunit report; without it the skip token alone converts") + unwrap.add_argument("--instr", required=True) + unwrap.set_defaults(func=cmd_unwrap_skips) + summ = sub.add_parser("summarize") + summ.add_argument("--report", required=True) + summ.add_argument("--root", required=True) + summ.add_argument("--manifest", required=True) + summ.add_argument("--plain-xml") + summ.add_argument("--instr-xml") + summ.add_argument("--timing") + summ.add_argument("--suite", default="Darling.Tests") + summ.add_argument("--tool", default="altcover") + summ.add_argument("--test-dir-prefix", action="append", default=["Darling/Darling.Tests", "Lite.Tests"]) + summ.add_argument("--summary-out", required=True) + summ.add_argument("--map-out", required=True) + summ.set_defaults(func=cmd_summarize) + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/ci-select.py b/.github/scripts/ci-select.py new file mode 100644 index 0000000000..eec905f8e9 --- /dev/null +++ b/.github/scripts/ci-select.py @@ -0,0 +1,1255 @@ +#!/usr/bin/env python3 +"""The ONE place CI's test-selection rules live (#5459). + +Input: the changed files of a run (and the event). Output: which test jobs run and in what scope. + +These readers use the same code: + +* build.yml's jobs call `lite-scope`, `darling-scope` and `tree-scope` with the area answers + dorny/paths-filter already computed, and get the decision back as `key=value` lines for $GITHUB_OUTPUT. +* `map-select` answers, from a class-to-file map, which test classes a pull request needs (build.yml's + test-map-select job writes it as an artifact), and `map-keep` reads that artifact for one suite: the Darling PG, + Lite and no-store Darling passes of a pull request keep only those classes, and run everything when the answer + is FULL, missing or unreadable. See map_select() for the map's schema. `--replay --map FILE` also replays the + corpus against such a map and reports what it would miss (it does not fail on that yet). +* `--replay` reads .github/ci-history/failures.jsonl (a trimmed record of every run with a real test failure) + and checks that each failing class would have been selected for that run's changed files. A cut to the rules + lands only while the replay finds 0 misses. A Guard-tagged test runs it on every pull request. + +The path patterns are NOT copied here. They are read from the filter blocks in .github/workflows/build.yml and +from .github/darling-paths-filter.yml, the same text dorny/paths-filter evaluates, so the replay and the +workflow cannot disagree about which area a file belongs to. What lives here is the decision logic that +used to be spread over `if:` expressions and a bash if-chain. + +Standard library only (the Windows and Linux runners both have Python 3). +""" +from __future__ import annotations + +import argparse +import datetime +import gzip +import json +import os +import re +import subprocess +import sys +import xml.etree.ElementTree as ET + +ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) +WORKFLOW = os.path.join(ROOT, ".github", "workflows", "build.yml") +DARLING_FILTER = os.path.join(ROOT, ".github", "darling-paths-filter.yml") +CORPUS = os.path.join(ROOT, ".github", "ci-history", "failures.jsonl") +ACCEPTED = os.path.join(ROOT, ".github", "ci-history", "replay-accepted.txt") + + +# --------------------------------------------------------------------------------------------------------- +# Glob matching: the subset of micromatch (dot: true) the filter files use. +# --------------------------------------------------------------------------------------------------------- + +def _segment_regex(seg: str) -> str: + """One path segment (no '/') as a regex fragment that matches a whole segment name.""" + m = re.fullmatch(r"!\((.*)\)", seg) + if m: + inner = _segment_regex(m.group(1)) + return rf"(?!(?:{inner})$)[^/]+" + out: list[str] = [] + i = 0 + while i < len(seg): + c = seg[i] + if c == "*": + out.append("[^/]*") + elif c == "?": + out.append("[^/]") + elif c == "{": + j = seg.index("}", i) + alts = seg[i + 1:j].split(",") + out.append("(?:" + "|".join(_segment_regex(a) for a in alts) + ")") + i = j + else: + out.append(re.escape(c)) + i += 1 + return "".join(out) + + +def compile_glob(pattern: str) -> re.Pattern[str]: + parts = pattern.split("/") + rx = "" + for idx, seg in enumerate(parts): + last = idx == len(parts) - 1 + if seg == "**": + rx += ".*" if last else "(?:[^/]+/)*" + else: + rx += _segment_regex(seg) + ("" if last else "/") + return re.compile(rx + r"\Z") + + +_GLOB_CACHE: dict[str, re.Pattern[str]] = {} + + +def matches(pattern: str, path: str) -> bool: + rx = _GLOB_CACHE.get(pattern) + if rx is None: + rx = _GLOB_CACHE[pattern] = compile_glob(pattern) + return rx.match(path) is not None + + +# --------------------------------------------------------------------------------------------------------- +# Reading the filter blocks. +# --------------------------------------------------------------------------------------------------------- + +_PATTERN_LINE = re.compile(r"^\s*-\s+'([^']*)'") +_KEY_LINE = re.compile(r"^(\s*)([A-Za-z0-9_-]+):\s*(?:#.*)?$") + + +def _parse_filter_lines(lines: list[str]) -> dict[str, list[str]]: + filters: dict[str, list[str]] = {} + current: list[str] | None = None + for line in lines: + s = line.strip() + if not s or s.startswith("#"): + continue + p = _PATTERN_LINE.match(line) + if p: + if current is not None: + current.append(p.group(1)) + continue + k = _KEY_LINE.match(line) + if k: + current = filters.setdefault(k.group(2), []) + return filters + + +def load_workflow_filters(path: str = WORKFLOW) -> dict[str, dict[str, list[str]]]: + """{job id: {filter name: [patterns]}} for every inline `filters: |` block in the workflow.""" + with open(path, encoding="utf-8") as fh: + lines = fh.read().split("\n") + out: dict[str, dict[str, list[str]]] = {} + job = "" + in_jobs = False + i = 0 + while i < len(lines): + line = lines[i] + if line.startswith("jobs:"): + in_jobs = True + elif in_jobs and re.match(r"^ [A-Za-z0-9_-]+:\s*$", line): + job = line.strip().rstrip(":") + m = re.match(r"^(\s+)filters:\s*\|\s*$", line) + if m: + base = len(m.group(1)) + block: list[str] = [] + i += 1 + while i < len(lines) and (not lines[i].strip() or len(lines[i]) - len(lines[i].lstrip()) > base): + block.append(lines[i]) + i += 1 + out[job] = _parse_filter_lines(block) + continue + i += 1 + return out + + +def load_file_filters(path: str = DARLING_FILTER) -> dict[str, list[str]]: + with open(path, encoding="utf-8") as fh: + return _parse_filter_lines(fh.read().split("\n")) + + +class Rules: + """The pattern sets, loaded once.""" + + def __init__(self, workflow: str = WORKFLOW, darling_filter: str = DARLING_FILTER) -> None: + wf = load_workflow_filters(workflow) + self.build = wf["build"] + self.lite_shards = wf["lite-tests"] + self.tree = wf["darling-tree-guards"] + self.guard = wf["guard-tests"] + self.gate = load_file_filters(darling_filter) + + @staticmethod + def _hits(patterns: list[str], files: list[str]) -> list[str]: + return [f for f in files if any(matches(p, f) for p in patterns)] + + def areas(self, files: list[str]) -> dict[str, bool]: + """Every area answer dorny/paths-filter gives a job, by name, for one list of changed files.""" + out: dict[str, bool] = {} + for name, pats in self.build.items(): + out[name] = bool(self._hits(pats, files)) + out["docs_count"] = len(self._hits(self.build["docs"], files)) # type: ignore[assignment] + out["all_count"] = len(files) # type: ignore[assignment] + for name, pats in self.gate.items(): + out["gate_" + name] = bool(self._hits(pats, files)) + for name, pats in self.lite_shards.items(): + out[name] = bool(self._hits(pats, files)) + out["workflow"] = any(matches(p, f) for p in self.tree["workflow"] for f in files) + out["lite_tree_count"] = len(self._hits(self.tree["lite_tree"], files)) # type: ignore[assignment] + return out + + +# --------------------------------------------------------------------------------------------------------- +# The decisions. +# --------------------------------------------------------------------------------------------------------- + +def darling_scope(event: str, runs: bool, shards_run: bool, full_paths: bool = True) -> str: + """What the build job's no-store "Run Darling tests" step runs: full, guard (only the Stage=Guard classes), + reads-lite (the Guard classes plus the Reads=Lite ones) or none. + + The PostgreSQL shards run every Darling.Tests class, with a live store, whenever the Darling gate + (.github/darling-paths-filter.yml) matches. The no-store pass would repeat their non-live classes, so while the + shards run it keeps only the Guard stage (cut 3b of #5459; the replay finds no failing class it stops covering). + A release always runs the whole suite. + + Cut 3a of #5459: the step also fires for a Lite file alone (Darling.Tests reads Lite source in its parity guards, + so a Lite-only change has to reach the suite). On a pull request that reaches it ONLY through those Lite paths + (`full_paths` false: no Darling file, shared library or root build file changed) the classes that read Lite are + the only ones the change can affect, so it runs the Guard stage and the classes tagged Reads=Lite, the mirror of + Lite's `reads` mode for a Darling-only change. Push and merge-queue runs keep the whole suite, as Lite's do.""" + if not runs: + return "none" + if event != "release" and shards_run: + return "guard" + if event == "pull_request" and not full_paths: + return "reads-lite" + return "full" + + +def tree_scope(event: str, all_count: int, lite_count: int) -> str: + """What darling-tree-guards runs once it has decided to run: full, or reads-lite (cut 3c of #5459). + + The job runs the Darling suite when the build job's Darling step was skipped, which for a change made entirely + of Lite files (a Lite.Tests edit, a Lite file type the `darling` filter does not name) leaves only the classes + that read Lite to run. Every changed file must be under Lite/ or Lite.Tests/, and only a pull request narrows.""" + if event == "pull_request" and all_count > 0 and lite_count == all_count: + return "reads-lite" + return "full" + + +def scope_filter(scope: str) -> str: + """The Darling.Tests runner arguments for a scope ('' is the whole suite).""" + if scope == "guard": + return "-trait Stage=Guard" + if scope == "reads-lite": + return "-trait Stage=Guard -trait Reads=Lite" + return "" + + +def lite_scope(event: str, lite: bool, core: bool, root: bool, reads: bool, linked: bool) -> str: + """How much of the Lite suite a pull request runs: full, reads (only Reads=Darling classes) or none.""" + if lite or core or root or linked: + return "full" + if reads and event != "pull_request": + return "full" + if reads: + return "reads" + return "none" + + +def decide(files: list[str], event: str, rules: Rules) -> dict: + """Which jobs run, and with which filter, for one change. Everything the workflow decides, in one place.""" + a = rules.areas(files) + release = event == "release" + all_count, docs_count = a["all_count"], a["docs_count"] + docs_only = all_count > 0 and all_count == docs_count + area_names = ("root", "core", "installer_core", "dashboard", "lite", "installer", "darling") + fastpath = (not release) and docs_only and not any(a[n] for n in area_names) + + build_lite = release or a["lite"] or a["core"] or a["root"] + build_installer = release or a["installer"] or a["installer_core"] or a["root"] + build_dashboard = release or a["dashboard"] or a["core"] or a["installer_core"] or a["root"] + build_darling = release or a["darling"] or a["core"] or a["root"] + + pg_run = (not release) and a["gate_darling"] + # The tree guards run when the build job's Darling step did not (or build.yml itself changed). + tree_run = (not release) and not fastpath and (not build_darling or a["workflow"]) + + mode = lite_scope(event, a["lite_shard"], a["core_shard"], a["root_shard"], a["darling_reads_shard"], + a["lite_linked_shard"]) if not release else "release" + + return { + "guard_run": (not release) and not docs_only, + "docs_fastpath": fastpath, + "build_lite_tests": build_lite, + "build_installer_tests": build_installer, + "build_dashboard_tests": build_dashboard, + "build_darling_tests": build_darling, + "darling_scope": darling_scope( + event, build_darling, pg_run, release or a["darling_full"] or a["core"] or a["root"]), + "darling_pg": pg_run, + "darling_linux": release or a["gate_darling"], + "lite_mode": mode, + "tree_guards": tree_run, + "tree_scope": tree_scope(event, all_count, a["lite_tree_count"]) if tree_run else "none", + } + + +# --------------------------------------------------------------------------------------------------------- +# The test classes of the tree (for the replay). +# --------------------------------------------------------------------------------------------------------- + +_CLASS_DECL = re.compile( + r"^[ \t]*(?:(?:public|internal|private|protected|sealed|static|abstract|partial)\s+)*class\s+(\w+)") +_TRAIT = re.compile(r'Trait\(\s*"(\w+)"\s*,\s*"(\w+)"\s*\)') + +# The test map is built on shards that set only DARLING_TEST_PG and DARLING_TEST_PGRUNTIME (nightly.yml's "Run this +# shard's classes" step; a test pins that env block to MAP_SHARD_ENV). A class that gates its tests on any OTHER +# DARLING_TEST_PG_* or DARLING_TEST_PGRUNTIME_* variable (the log-format targets, the log-rotation targets, the +# store-upgrade fixtures) skips at map build, so the map has no coverage edges for what its tests would run and a +# change to the code under them would never select it (#5459; measured on the 2026-10-09 map: 16 files, 19 +# mapped classes, 12 to 44 coverage files each against 100 and more for a fully covered class). The map cannot tell +# us, so the SOURCE does: every class in a file that names such a variable in a quoted literal carries the Gate=Env +# trait and map_select always selects it. It is found from the source at selection time, so a new gated class needs +# no list edit. +# +# What the scan cannot see (a miss here is silent, so a new gate must be written to be seen): it reads each test file +# on its own, for a quoted "DARLING_TEST_PG_..." or "DARLING_TEST_PGRUNTIME_..." literal. A gate variable that a class +# reads through a constant or a helper declared in ANOTHER file, or one that is not named DARLING_TEST_PG_* / +# DARLING_TEST_PGRUNTIME_*, is invisible to it, and the class stays unmarked and unmapped. A new gate of either kind +# must also name its variable as a quoted literal in the class's own file (or take the Gate=Env trait by hand). +# +# What it costs: the mark is per CLASS, not per test, so a class where only some tests are gated runs whole on every +# narrowed Darling pull request, and the darling-pg job sets these variables, so the gated tests really run. Measured +# on a full run of this change (workflow run 37911378693, the 19 test classes that carry the mark, summed test time +# per class, the shard each lands on in brackets): DarlingStoreUpgradeTests 274 s [shard 5], +# ManagedConfUpgradePathTests 85 s [4], DarlingManagedPostgresTests 73 s [3], PgServerLogTailCsvJsonRotationLiveTests +# 44 s [1], PgLogBurstAndRotationLiveTests 38 s [2], PgRaiseShapedRecordsLiveTests 31 s [1], +# PgServerLogTailRotationLiveTests 15 s [1], and 12 more classes of 11 s or less (29 s together): about 590 s of test +# time in all. Per shard that is 274 s on shard 5, 100 s on shard 4, 90 s on shard 1, 73 s on shard 3, 43 s on shard 2 +# and 9 s on shard 0. A class runs on one thread, so shard 5 cannot get below the 274 s of DarlingStoreUpgradeTests: on +# a narrowed pull request whose other shards finish in a minute or two, shard 5 becomes the slowest by about 3 +# minutes (it is 174 s ahead of shard 4, the next). It never passes the full run's slowest shard (shard 0, 589 s of +# test time, against 509 s for shard 5 in that run). The shard cut is unchanged. +MAP_SHARD_ENV = frozenset({"DARLING_TEST_PG", "DARLING_TEST_PGRUNTIME"}) +GATE_TRAIT = "Gate=Env" +GATE_ENV_LITERAL = re.compile(r'"DARLING_TEST_PG(?:RUNTIME)?_[A-Z0-9_]+"') + +SUITE_DIRS = { + "darling": os.path.join("Darling", "Darling.Tests"), + "lite": "Lite.Tests", + "dashboard": os.path.join("deprecated", "Dashboard.Tests"), + "installer": os.path.join("deprecated", "Installer.Tests"), +} +SUITE_BY_PREFIX = { + "Darling.Tests": "darling", + "PerformanceMonitor.Darling.Tests": "darling", + "Lite.Tests": "lite", + "PerformanceMonitorLite.Tests": "lite", + "Dashboard.Tests": "dashboard", + "Installer.Tests": "installer", +} + + +def split_failed_class(cls: str) -> tuple[str, str]: + """(namespace prefix, simple class name) of a failing test class's full name. A nested class is reported as + `Ns.Outer+Inner` (some runners use `Ns.Outer/Inner`): the prefix is the namespace of the OUTER class, the text + before the first '+', and the simple name is the innermost class, which is the name _walk_classes yields for a + nested declaration. Splitting on the last '.' alone left `Outer+Inner` as the "simple name", found no such class + in the tree, and so never counted a nested failure as a miss (#5459).""" + head = re.split(r"[+/]", cls, maxsplit=1)[0] + return head.rpartition(".")[0], re.split(r"[.+/]", cls)[-1] + + +def _walk_classes(root: str): + """Yield (suite, repo-relative file with '/', simple class name, {"Key=Value", ...}) for every class declaration.""" + for suite, rel in SUITE_DIRS.items(): + base = os.path.join(root, rel) + for dp, dn, fn in os.walk(base): + dn[:] = [d for d in dn if d not in ("bin", "obj")] + for f in fn: + if not f.endswith(".cs"): + continue + path = os.path.join(dp, f) + try: + with open(path, encoding="utf-8-sig") as fh: + lines = fh.read().split("\n") + except OSError: + continue + relfile = os.path.relpath(path, root).replace(os.sep, "/") + gated = any(GATE_ENV_LITERAL.search(line) for line in lines) + for n, line in enumerate(lines): + m = _CLASS_DECL.match(line) + if not m: + continue + traits: set[str] = {GATE_TRAIT} if gated else set() + j = n - 1 + while j >= 0: + s = lines[j].strip() + if s.startswith(("[", "///", "//", "*", "/*")) or s.endswith("*/") or s == "" and False: + traits.update(f"{k}={v}" for k, v in _TRAIT.findall(s)) + j -= 1 + else: + break + yield suite, relfile, m.group(1), traits + + +def scan_classes(root: str = ROOT) -> dict[str, dict[str, set[str]]]: + """{suite: {simple class name: {"Key=Value", ...}}} read from the test sources.""" + out: dict[str, dict[str, set[str]]] = {suite: {} for suite in SUITE_DIRS} + for suite, _file, name, traits in _walk_classes(root): + out[suite].setdefault(name, set()).update(traits) + return out + + +def class_selected(suite: str, traits: set[str], d: dict) -> bool: + """Would today's rules run this class for this decision?""" + guard = "Stage=Guard" in traits + if suite == "darling": + if guard and d["guard_run"]: + return True + # The build job's no-store pass runs the whole suite (full) or only the Guard stage; the shards run every class. + reads_lite = "Reads=Lite" in traits + return d["darling_scope"] == "full" or d["darling_pg"] \ + or (d["tree_guards"] and (d["tree_scope"] == "full" or reads_lite)) \ + or (d["darling_scope"] == "guard" and guard) \ + or (d["darling_scope"] == "reads-lite" and (guard or reads_lite)) + if suite == "lite": + if guard and d["guard_run"]: + return True + if d["lite_mode"] in ("full", "release"): + return True + return d["lite_mode"] == "reads" and "Reads=Darling" in traits + if suite == "dashboard": + return d["build_dashboard_tests"] + if suite == "installer": + return d["build_installer_tests"] + return True + + +# --------------------------------------------------------------------------------------------------------- +# Slow classes: pull requests skip them unless the change reaches them (#5459, change 5). +# --------------------------------------------------------------------------------------------------------- +# +# A class tagged [Trait("Cost", "Slow")] is slow, has never failed in the corpus, and is covered by the push to dev +# one merge later. A pull_request run leaves it out unless the change reaches it. Push, merge-queue, nightly and +# release runs run every class: slow_skip answers nothing for them. This is a separate pass over the classes a leg +# already chose, not part of darling_scope or lite_scope, so a new scope composes with it. +# +# "Reaches it", from the changed file names alone (the same cheap signal the area filters use): +# * the file is a source file the class is declared in; +# * the file's name (without the extension) appears as a word in the class's source, which is how a test names the +# product file, harness script or helper it exercises; +# * a .js/.mjs/.css/.html file, and a class whose source names a .js or .mjs file (the Node-driven classes load +# scripts through a harness that imports others); +# * any non-.cs file inside the class's own test project (a csproj, a fixture, a harness), or a build input +# (the workflow, this script, the shard scripts, a props/csproj/lock file, global.json). +# When the changed file list is unknown (the API call failed, or it came back empty) nothing is skipped. + +SLOW_TRAIT = "Cost=Slow" +_BUILD_INPUT_NAMES = {"directory.build.props", "directory.build.targets", "directory.packages.props", "global.json", + "nuget.config", "packages.lock.json"} +_BUILD_INPUT_PATHS = {".github/workflows/build.yml", ".github/scripts/ci-select.py", ".github/darling-paths-filter.yml", + ".github/scripts/run-darling-pg-shard.ps1", ".github/scripts/run-lite-shard.ps1", + ".github/scripts/lite-shard-pack.py"} +_BUILD_INPUT_SUFFIXES = (".csproj", ".props", ".targets", ".sln", ".slnx") +_WEB_SUFFIXES = (".js", ".mjs", ".css", ".html") + + +def scan_slow(root: str = ROOT) -> dict[str, dict[str, list[str]]]: + """{suite: {class: [repo-relative source files]}} for every class tagged Cost=Slow (a Guard class never counts: + the Guard stage runs on every pull request).""" + files: dict[tuple[str, str], list[str]] = {} + traits: dict[tuple[str, str], set[str]] = {} + for suite, relfile, name, t in _walk_classes(root): + files.setdefault((suite, name), []) + if relfile not in files[(suite, name)]: + files[(suite, name)].append(relfile) + traits.setdefault((suite, name), set()).update(t) + out: dict[str, dict[str, list[str]]] = {suite: {} for suite in SUITE_DIRS} + for (suite, name), t in traits.items(): + if SLOW_TRAIT in t and "Stage=Guard" not in t: + out[suite][name] = sorted(files[(suite, name)]) + return out + + +class SlowIndex: + """The Cost=Slow classes of the tree and what reaches them.""" + + def __init__(self, root: str = ROOT) -> None: + self.root = root + self.slow = scan_slow(root) + self._text: dict[tuple[str, str], str] = {} + self._words: dict[tuple[str, str], set[str]] = {} + + def text(self, suite: str, name: str) -> str: + key = (suite, name) + if key not in self._text: + parts = [] + for rel in self.slow[suite][name]: + try: + with open(os.path.join(self.root, rel), encoding="utf-8-sig") as fh: + parts.append(fh.read()) + except OSError: + pass + self._text[key] = "\n".join(parts) + return self._text[key] + + def words(self, suite: str, name: str) -> set[str]: + """The class source's words (letters, digits, underscore, hyphen), the unit a file stem is matched against.""" + key = (suite, name) + if key not in self._words: + self._words[key] = set(re.findall(r"[\w-]+", self.text(suite, name))) + return self._words[key] + + def skip(self, files: list[str], event: str, base_ref: str = "") -> dict[str, list[str]]: + """{suite: [simple class names a leg leaves out]} for one change. Empty for every event but pull_request, + for a pull request into main (narrows), and when the changed file list is unknown.""" + out: dict[str, list[str]] = {suite: [] for suite in SUITE_DIRS} + if not narrows(event, base_ref) or not files: + return out + changed = set(files) + # Everything about the change is computed once; a class is then a few set lookups. + stems: set[str] = set() + odd_stems: set[str] = set() + exts: set[str] = set() + build_input = False + for f in changed: + low = f.lower() + base = f.rsplit("/", 1)[-1] + stem, dot, ext = base.rpartition(".") + ext = dot + ext.lower() if dot else "" + if low in _BUILD_INPUT_PATHS or base.lower() in _BUILD_INPUT_NAMES or low.endswith(_BUILD_INPUT_SUFFIXES): + build_input = True + if stem: + (stems if re.fullmatch(r"[\w-]+", stem) else odd_stems).add(stem) + if ext in _WEB_SUFFIXES: + exts.add(ext) + if build_input: + return out + for suite, classes in self.slow.items(): + test_dir = SUITE_DIRS[suite].replace(os.sep, "/") + "/" + if any(f.startswith(test_dir) and not f.endswith(".cs") for f in changed): + continue + for name, own in classes.items(): + text = self.text(suite, name) + reached = bool(changed.intersection(own)) or bool(stems & self.words(suite, name)) \ + or any(re.search(r"(? list[str]: + """The pull request's changed files (both sides of a rename) through `gh api`; [] when the call fails.""" + import subprocess + try: + r = subprocess.run(["gh", "api", f"repos/{repo}/pulls/{pr}/files", "--paginate", "--jq", + '.[] | .filename, (.previous_filename // empty)'], + capture_output=True, text=True, timeout=120) + except (OSError, subprocess.SubprocessError) as e: + print(f"slow-skip: could not list the pull request's files ({e}); skipping nothing", file=sys.stderr) + return [] + if r.returncode != 0: + print(f"slow-skip: gh api exited {r.returncode}; skipping nothing", file=sys.stderr) + return [] + out = [ln.strip() for ln in r.stdout.splitlines() if ln.strip()] + if len(out) >= 3000: # the API lists at most 3000 files, so a list this long may be cut: do not trust it + print("slow-skip: the file list may be truncated; skipping nothing", file=sys.stderr) + return [] + return out + + +def load_accepted(path: str = ACCEPTED) -> set[tuple[str, str]]: + """The rows today's rules miss for a reason that is not a selection gap. One ` ` per line, + the reason in a `#` comment above the group. A miss that is not listed here fails the replay.""" + out: set[tuple[str, str]] = set() + if os.path.exists(path): + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.split("#", 1)[0].strip() + if line: + sha, cls = line.split()[:2] + out.add((sha, cls)) + return out + + +def replay(corpus: str = CORPUS, root: str = ROOT, quiet: bool = False) -> int: + rules = Rules() + accepted = load_accepted() + accepted_seen = 0 + tree = scan_classes(root) + slow = SlowIndex(root) + rows = checked = misses = exempt = 0 + gone: set[str] = set() + missed: list[str] = [] + with open(corpus, encoding="utf-8") as fh: + for line in fh: + if not line.strip(): + continue + row = json.loads(line) + rows += 1 + d = decide(row["changed_files"], row["event"], rules) + skipped = slow.skip(row["changed_files"], row["event"]) + for cls in row["failed_classes"]: + prefix, simple = split_failed_class(cls) + suite = SUITE_BY_PREFIX.get(prefix) + if suite is None: + continue + traits = tree.get(suite, {}).get(simple) + if traits is None: + exempt += 1 + gone.add(cls) + continue + checked += 1 + if simple in skipped.get(suite, ()) or not class_selected(suite, traits, d): + if (row["head_sha"][:10], cls) in accepted: + accepted_seen += 1 + continue + misses += 1 + missed.append(f"{row['date']} {row['head_sha'][:10]} {cls} files={len(row['changed_files'])}" + f" e.g. {row['changed_files'][0]}") + if not quiet: + print(f"replay: {rows} rows, {checked} failing classes checked, {exempt} exempt (class no longer in the tree: " + f"{len(gone)} distinct), {accepted_seen} accepted misses " + f"(replay-accepted.txt), {misses} misses") + for m in missed[:40]: + print("MISS", m) + return misses + + +# --------------------------------------------------------------------------------------------------------- +# Selection from a class-to-file map (#5459 change 4, slice 1: nothing in build.yml calls this yet). +# --------------------------------------------------------------------------------------------------------- + +MAP_SCHEMA = 1 +MAP_MAX_AGE_DAYS = 7 +MAP_MAX_DRIFT = 300 + +# Files whose change can alter any test in ways a call map cannot see: the build and restore configuration. +# selection_args() adds the root and shared-library areas read from build.yml, so the lists cannot disagree. +KEEP_FULL = ( + ".github/workflows/build.yml", + "Directory.Build.props", + "Directory.Build.targets", + "Directory.Packages.props", + "global.json", + "nuget.config", + "NuGet.config", + "**/*.csproj", + "**/*.props", + "**/*.targets", + "**/packages.lock.json", + "**/xunit.runner.json", +) + +# The documentation allowlist (the `docs` filter in build.yml); selection_args() passes the live one. +DOC_PATTERNS = ( + "**/*.md", + "LICENSE", + "CITATION.cff", + ".gitignore", + ".gitattributes", + "llms.txt", + "tools/changelog/archive-census.txt", + "docs/**/*.{md,svg,png,jpg,jpeg,gif}", + "Screenshots/**/*.{md,svg,png,jpg,jpeg,gif}", +) + + +_TEST_PROJECT_FILE = re.compile(r"(^|/)[^/]*Tests/") + + +MAIN_BRANCH = "main" + + +def narrows(event: str, base_ref: str = "") -> bool: + """Whether a run may run less than everything: only a pull_request whose base branch is not `main` (#5459). + A pull request into main is the release gate (dev to main), so it runs every class, Cost=Slow ones included. + An empty or absent base ref keeps the old behaviour: the replay corpus rows carry none.""" + if event != "pull_request": + return False + ref = (base_ref or "").strip() + if ref.startswith("refs/heads/"): + ref = ref[len("refs/heads/"):] + return ref != MAIN_BRANCH + + +def _full(reason: str) -> dict: + return {"full": True, "reason": reason, "selected": {}, "why": {}, "seconds": {}, "total": {}} + + +def _ids(value: object, universe: list) -> set[str]: + """Paths for a list of file ids. An id is an index into the map's `files`; a string is taken as the path itself.""" + out: set[str] = set() + if value is None: + return out + if isinstance(value, (int, str)): + value = [value] + for v in value: # type: ignore[union-attr] + if isinstance(v, bool): + continue + if isinstance(v, int): + if 0 <= v < len(universe): + out.add(universe[v]) + elif isinstance(v, str): + out.add(v) + return out + + +def _seconds(entry: dict) -> float: + v = entry.get("seconds", 0) + return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else 0.0 + + +def _parse_time(text: object) -> "datetime.datetime | None": + if not isinstance(text, str): + return None + try: + t = datetime.datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + return t if t.tzinfo else t.replace(tzinfo=datetime.timezone.utc) + + +def map_select(test_map: object, changed: list[str], drift: "list[str] | None", discovered: dict, + *, event: str = "pull_request", keep_full: "tuple[str, ...] | list[str]" = KEEP_FULL, + doc_patterns: "tuple[str, ...] | list[str]" = DOC_PATTERNS, + now: "datetime.datetime | None" = None, max_age_days: "float | None" = MAP_MAX_AGE_DAYS, + max_drift: int = MAP_MAX_DRIFT, base_ref: str = "") -> dict: + """Which test classes a pull request runs, from a class-to-file map. Pure: no file, git or clock access + (`now` is the clock; `max_age_days=None` turns the age check off, for a replay of old runs). + + Schema 1 of the map (nightly's `test-map.json.gz`): + + {"schema": 1, "sha": "", "built_at": "", "tool": "", + "files": ["", ...], # the universe; an id below is an index into it + "classes": {"": {"": {"own": , # the class's own source file(s) + "files": [, ...], # every file the class ran code from + "seconds": }}}, # its last measured run time + "text_patterns": {"": ["", ...] or "tree"}} # sources read as text; "tree" = any + + Arguments: `changed` is the pull request's files; `drift` the files changed between the map's commit and the + merge base (None when they could not be computed); `discovered` is `{suite: {Class: {"Key=Value", ...}}}` as + scan_classes() returns it, which is how a class the map has never seen is found. + + A class is selected when it carries Stage=Guard, its own file changed, it covers a changed file, a text pattern + of it matches a changed file, it reads the whole tree and any non-documentation file changed, or the map has + never seen it. A file changed since the map counts as changed too (it may have grown new call edges). + A keep-full file forces FULL only when the pull request's own change holds it: one that changed on dev since + the map goes through the class links like any other drifted file. + + Returns `{"full": bool, "reason": str, "selected": {suite: [Class, ...]}, "why": {suite: {Class: reason}}, + "seconds": {suite: float}, "total": {suite: int}}`. FULL (never "nothing") whenever the map cannot be trusted: + missing, unreadable, wrong schema, too old, drift over `max_drift` files, a keep-full file, a file the map knows + nothing about, or any event but a pull request. That includes a new product .cs file: types found by reflection, + DI or attribute scanning change what a census class sees without any file it covers changing. The one exception + is a new .cs file under a test project (a `*Tests/` or `*.Tests/` directory) while `discovered` holds a class the + map has never seen; that class runs as a "new class".""" + if event != "pull_request": + return _full(f"event {event or '(none)'} always runs everything") + if not narrows(event, base_ref): + return _full("a pull request into main always runs everything") + if test_map is None: + return _full("no test map") + if not isinstance(test_map, dict): + return _full("test map unreadable") + if test_map.get("schema") != MAP_SCHEMA: + return _full(f"test map schema {test_map.get('schema')!r}, expected {MAP_SCHEMA}") + universe = test_map.get("files") + classes = test_map.get("classes") + patterns = test_map.get("text_patterns", {}) + if not isinstance(universe, list) or not isinstance(classes, dict) or not isinstance(patterns, dict): + return _full("test map unreadable") + if max_age_days is not None: + built = _parse_time(test_map.get("built_at")) + if built is None: + return _full("test map has no readable build time") + clock = now or datetime.datetime.now(datetime.timezone.utc) + if clock - built > datetime.timedelta(days=max_age_days): + return _full(f"test map older than {max_age_days:g} days") + if drift is None: + return _full("drift since the map's commit unknown") + if len(drift) > max_drift: + return _full(f"{len(drift)} files changed since the map (over {max_drift})") + + # Keep-full is about THIS pull request's own change (merge base to head). A keep-full file that only changed on dev + # since the map's commit is not this pull request's doing and the dev run that merged it already tested it, so it + # goes through the class links like any other drifted file (#5459: ten pull requests were FULL for that alone). + for f in changed: + if any(matches(p, f) for p in keep_full): + return _full(f"{f} is a keep-full file") + effective = list(dict.fromkeys(list(changed) + list(drift))) + live = [f for f in effective if not any(matches(p, f) for p in doc_patterns)] + live_names = set(live) + + known: set[str] = {u for u in universe if isinstance(u, str)} + parsed: dict[str, dict[str, tuple[set[str], set[str]]]] = {} + for suite, entries in classes.items(): + if not isinstance(entries, dict): + return _full("test map unreadable") + parsed[suite] = {} + for cls, e in entries.items(): + if not isinstance(e, dict): + return _full("test map unreadable") + own, covered = _ids(e.get("own"), universe), _ids(e.get("files"), universe) + parsed[suite][cls] = (own, covered) + known |= own | covered + globs: dict[str, "list[str] | str"] = {} + for cls, p in patterns.items(): + if p == "tree" or (isinstance(p, list) and all(isinstance(x, str) for x in p)): + globs[cls] = p + else: + return _full("test map unreadable") + + docs_changed = [f for f in effective if f not in live_names] + + def text_hit(cls: str) -> bool: + g = globs.get(cls) + if g == "tree": + return bool(live) + if not isinstance(g, list): + return False + # A class that reads a documentation file by name (the migration ladder reads CHANGELOG.md) is selected by a + # change to exactly that file; the documentation allowlist only keeps wildcard patterns from matching it. + return (any(matches(p, f) for p in g for f in live) + or any(p == f for p in g if "*" not in p for f in docs_changed)) + + new_classes = any(cls not in parsed.get(suite, {}) for suite, found in discovered.items() for cls in found) + for f in live: + if f in known: + continue + if any(isinstance(g, list) and any(matches(p, f) for p in g) for g in globs.values()): + continue + if f.endswith(".cs"): + if _TEST_PROJECT_FILE.search(f) and new_classes: + continue # a new test file: its classes are found through `discovered` + return _full(f"new file not in the map: {f}") + return _full(f"{f} is in no class, no text pattern and not in the map") + + # A class the map has never seen runs because `discovered` lists it. If the tree scan found no class at all for a + # suite the map has classes for, the scan itself is broken and every new class would be silently left out. + for suite, entries in parsed.items(): + if entries and not discovered.get(suite): + return _full(f"no test class was found in the tree for suite {suite}") + + live_set = set(live) + selected: dict[str, list[str]] = {} + why: dict[str, dict[str, str]] = {} + seconds: dict[str, float] = {} + total: dict[str, int] = {} + for suite in sorted(set(parsed) | set(discovered)): + found = discovered.get(suite, {}) + mapped = parsed.get(suite, {}) + reasons: dict[str, str] = {} + for cls, (own, covered) in mapped.items(): + if live_set & own: + reasons[cls] = "own file changed" + elif live_set & covered: + reasons[cls] = "covers a changed file" + elif text_hit(cls): + reasons[cls] = "reads the tree" if globs.get(cls) == "tree" else "text pattern" + for cls, traits in found.items(): + if cls not in mapped: + reasons[cls] = "new class" + elif "Stage=Guard" in traits: + reasons.setdefault(cls, "guard stage") + if GATE_TRAIT in traits: + reasons.setdefault(cls, "gated on an environment the map shards lack") + selected[suite] = sorted(reasons) + why[suite] = {c: reasons[c] for c in sorted(reasons)} + seconds[suite] = round(sum(_seconds(classes[suite][c]) for c in reasons if c in mapped), 3) + total[suite] = len(set(mapped) | set(found)) + return {"full": False, "reason": "", "selected": selected, "why": why, "seconds": seconds, "total": total} + + +def load_map(path: str) -> "dict | None": + """The map file (plain or gzip), or None when it is missing or not JSON.""" + try: + opener = gzip.open if path.endswith(".gz") else open + with opener(path, "rt", encoding="utf-8") as fh: # type: ignore[operator] + return json.load(fh) + except (OSError, ValueError): + return None + + +def git_drift(map_sha: str, base: str, root: str = ROOT) -> "list[str] | None": + """`git diff --no-renames --name-only ..`: both paths of a rename count. None when git cannot say.""" + try: + out = subprocess.run(["git", "-C", root, "diff", "--no-renames", "--name-only", f"{map_sha}..{base}"], + capture_output=True, text=True, timeout=120, check=True).stdout + except (OSError, subprocess.SubprocessError): + return None + return [ln.strip() for ln in out.split("\n") if ln.strip()] + + +def selection_args(rules: Rules) -> dict: + """The keep-full and documentation patterns, read from the same filters the workflow evaluates.""" + keep = list(KEEP_FULL) + for name in ("root", "core", "installer_core"): + keep.extend(rules.build.get(name, [])) + return {"keep_full": tuple(dict.fromkeys(keep)), "doc_patterns": tuple(rules.build["docs"])} + + +def map_replay(test_map: "dict | None", corpus: str = CORPUS, root: str = ROOT, quiet: bool = False, + misses_out: "str | None" = None) -> int: + """Like replay(), but asks the map: would each failing class of each corpus row have been selected, taking the map + as current (no drift, no age check)? Returns the miss count. Reports only; the caller does not fail on it yet. + The printed list is capped at 40; `misses_out` names a file that gets EVERY miss as one JSON row per line + (date, event, head_sha, suite, class, changed_files), so none goes unclassified (#5459).""" + extra = selection_args(Rules()) + accepted = load_accepted() + tree = scan_classes(root) + rows = checked = misses = full_rows = accepted_seen = 0 + missed: list[str] = [] + rows_out: list[dict] = [] + with open(corpus, encoding="utf-8") as fh: + for line in fh: + if not line.strip(): + continue + row = json.loads(line) + rows += 1 + sel = map_select(test_map, row["changed_files"], [], tree, event=row["event"], max_age_days=None, **extra) + full_rows += bool(sel["full"]) + for cls in row["failed_classes"]: + prefix, simple = split_failed_class(cls) + suite = SUITE_BY_PREFIX.get(prefix) + if suite is None or simple not in tree.get(suite, {}): + continue + checked += 1 + if sel["full"] or simple in sel["selected"].get(suite, []): + continue + if (row["head_sha"][:10], cls) in accepted: + accepted_seen += 1 + continue + misses += 1 + missed.append(f"{row['date']} {row['head_sha'][:10]} {cls} files={len(row['changed_files'])}" + f" e.g. {row['changed_files'][0]}") + rows_out.append({"date": row["date"], "event": row["event"], "head_sha": row["head_sha"], + "suite": suite, "class": cls, "changed_files": row["changed_files"]}) + if not quiet: + print(f"map replay: {rows} rows ({full_rows} FULL), {checked} failing classes checked, {accepted_seen} accepted, " + f"{misses} misses (reported, not failing yet)") + for m in missed[:40]: + print("MAP-MISS", m) + if len(missed) > 40: + print(f"... {len(missed) - 40} more misses not printed; --misses-out FILE lists every one") + if misses_out: + with open(misses_out, "w", encoding="utf-8", newline="\n") as out: + for r in rows_out: + out.write(json.dumps(r, sort_keys=True) + "\n") + return misses + + +def map_keep(selection: object, suite: str) -> "tuple[set[str] | None, str]": + """The class names a shard of `suite` keeps, from the selection `map-select --out` wrote (#5459, live selection). + + Returns (names, reason). `names` is None, meaning the shard runs everything it was cut, whenever the selection + cannot be trusted: it is missing, not a JSON object, FULL, has no list of names for the suite, a name that is not + text, or an empty list. Empty is distrusted on purpose: the Guard classes are always selected, so a selection with + no class in it for a suite means the selection broke, not that nothing needs to run.""" + if not isinstance(selection, dict): + return None, "the selection is missing or unreadable" + if selection.get("full") is not False: + return None, f"the selection is FULL ({selection.get('reason') or 'no reason given'})" + chosen = selection.get("selected") + names = chosen.get(suite) if isinstance(chosen, dict) else None + if not isinstance(names, list) or not names or not all(isinstance(n, str) and n for n in names): + return None, f"the selection holds no usable class list for the {suite} suite" + return set(names), "" + + +def shadow_summary(result: dict) -> str: + """The step-summary text of the selection job: how many classes the map picked, and the estimated seconds.""" + lines = ["### Test map selection (#5459): the Darling PG, Lite and no-store Darling passes run only the classes below", ""] + if result.get("full"): + lines.append(f"Selection: FULL ({result.get('reason') or 'no reason given'}), so every shard ran everything.") + return "\n".join(lines) + "\n" + lines += ["Estimated seconds are the nightly's instrumented times, so they read high.", "", + "| suite | selected classes | of | estimated seconds |", "|---|---|---|---|"] + for suite in sorted(result.get("selected", {})): + lines.append(f"| {suite} | {len(result['selected'][suite])} | {result['total'].get(suite, 0)} | " + f"{result['seconds'].get(suite, 0):.0f} |") + for suite in sorted(result.get("why", {})): + counts: dict[str, int] = {} + for reason in result["why"][suite].values(): + counts[reason] = counts.get(reason, 0) + 1 + lines.append(f"- {suite} by reason: " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items()))) + return "\n".join(lines) + "\n" + + +REPORT_ATTEMPT = re.compile(r"-a(\d+)\.xml$") + + +def failed_classes(reports: str) -> "tuple[dict[str, set[str]], dict[str, int], int]": + """The classes with a failed test in the xunit reports under `reports`, one subfolder per uploaded artifact: the + shards' `darling-tests-timing-N` and `lite-tests-timing-N`, and the Guard tests job's `guard-tests-timing-darling` + and `guard-tests-timing-lite` (#5459). The suite is the one the folder name says. + + A report named `...-a.xml` was written by run attempt N. Staleness is decided per artifact folder: within a + folder, the reports of the highest attempt present are kept and the reports of a lower attempt are left out and + counted as stale. A shard that failed in attempt 1 and passed in attempt 2 uploads its artifact again with + `overwrite`, so the folder holds attempt 2 only; if an old attempt-1 file is still there, it must not list its + attempt-1 class (#5459, run 37776652739). A shard that passed in attempt 1 is not re-run by "Re-run failed jobs", + so its folder still holds only `-a1` files and they count in attempt 2. A report with no attempt in its name + always counts. Returns ({suite: {Class}}, {suite: report count}, stale report count).""" + failed: dict[str, set[str]] = {"darling": set(), "lite": set()} + seen: dict[str, int] = {"darling": 0, "lite": 0} + stale = 0 + if not os.path.isdir(reports): + return failed, seen, stale + # (artifact folder, path) of every report, with the attempt its name carries (None when it carries none). + found: list[tuple[str, str, "int | None"]] = [] + for dirpath, _, names in os.walk(reports): + top = os.path.relpath(dirpath, reports).replace("\\", "/").split("/")[0] + for name in names: + if name.endswith(".xml"): + written_by = REPORT_ATTEMPT.search(name) + found.append((top, os.path.join(dirpath, name), int(written_by.group(1)) if written_by else None)) + latest: dict[str, int] = {} + for top, _, written_by in found: + if written_by is not None: + latest[top] = max(latest.get(top, written_by), written_by) + for top, path, written_by in found: + suite = "darling" if "darling" in top else "lite" if "lite" in top else "" + if not suite: + continue + if written_by is not None and written_by < latest[top]: + stale += 1 + continue + seen[suite] += 1 + try: + for _, el in ET.iterparse(path, events=("end",)): + if el.tag == "test" and (el.get("result") or "").lower() == "fail": + failed[suite].add(re.split(r"[.+/]", el.get("type") or "")[-1]) + el.clear() + except (ET.ParseError, OSError): + seen[suite] -= 1 # an unreadable report counts as no report + return failed, seen, stale + + +def failed_jobs(path: "str | None") -> "list[str] | None": + """Names of the jobs that concluded `failure`, from the latest attempt's job list (`gh api .../attempts/N/jobs`: one + `{"name", "conclusion"}` object per line, or the API's own `{"jobs": [...]}` object). None when it cannot be read. + A job that failed without a failed test (the whole-tree guards job ending on a leaked foreground thread) shows up + here and nowhere else.""" + if not path: + return None + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except OSError: + return None + items: list = [] + try: + data = json.loads(text) + if isinstance(data, dict): + items = data["jobs"] if isinstance(data.get("jobs"), list) else [data] + elif isinstance(data, list): + items = data + except ValueError: + for line in text.splitlines(): + try: + items.append(json.loads(line)) + except ValueError: + continue + return sorted({str(j.get("name")) for j in items if isinstance(j, dict) and j.get("conclusion") == "failure"}) + + +def shadow_row(selection: "dict | None", failed: "dict[str, set[str]]", seen: "dict[str, int]", meta: dict, + stale: int = 0, jobs: "list[str] | None" = None) -> dict: + """One JSONL row per run: the selection's size and the classes that failed although it would not have run them. + A FULL (or missing) selection selects every class, so it can have no miss. `stale` counts the reports left out as + a lower attempt's in their artifact folder; `jobs` is the latest attempt's failed jobs (None when unknown).""" + if isinstance(selection, dict): + sel = selection + else: + # No selection file. When the selection job itself did not finish (a newer push cancels the run), say so: that + # is expected, not a script fault (#5459, run 37832135291). + job = str(meta.get("selection_job") or "") + sel = _full(f"the selection job ended {job}, so no selection was written" if job and job != "success" + else "no selection was written") + misses: list[dict] = [] + if not sel.get("full"): + for suite in sorted(failed): + chosen = set(sel.get("selected", {}).get(suite, [])) + misses += [{"suite": suite, "class": c} for c in sorted(failed[suite]) if c not in chosen] + row = {"v": 1, **meta, "full": bool(sel.get("full")), "reason": sel.get("reason", ""), + "selected": {s: len(v) for s, v in sel.get("selected", {}).items()}, + "total": sel.get("total", {}), "seconds": sel.get("seconds", {}), + "reports": seen, "stale_reports": stale, "failed": {s: sorted(v) for s, v in failed.items()}, + "misses": misses} + if jobs is not None: + row["failed_jobs"] = jobs + return row + + +# --------------------------------------------------------------------------------------------------------- +# CLI. +# --------------------------------------------------------------------------------------------------------- + +def _bool(s: str) -> bool: + return s.strip().lower() == "true" + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--replay", action="store_true", help="replay the failure corpus against today's rules") + ap.add_argument("--corpus", default=CORPUS) + ap.add_argument("--map", help="with --replay: also replay the corpus against this class-to-file map (reports only)") + ap.add_argument("--misses-out", help="with --map: write every map miss, one JSON row per line, to this file") + sub = ap.add_subparsers(dest="cmd") + + ls = sub.add_parser("lite-scope", help="how much of the Lite suite a leg runs (workflow step)") + ls.add_argument("--event", required=True) + ls.add_argument("--shard", required=True) + for k in ("lite", "core", "root", "reads", "linked"): + ls.add_argument(f"--{k}", default="false") + + ds = sub.add_parser("darling-scope", help="what the build job's no-store Darling pass runs (workflow step)") + ds.add_argument("--event", required=True) + ds.add_argument("--gate", default="false", help="the Darling PostgreSQL gate's answer (darling-paths-filter.yml)") + for k in ("full", "core", "root"): + ds.add_argument(f"--{k}", default="true", help="a path that runs the whole Darling suite changed") + + ts = sub.add_parser("tree-scope", help="what darling-tree-guards runs once it runs (workflow step)") + ts.add_argument("--event", required=True) + ts.add_argument("--all-count", type=int, default=0) + ts.add_argument("--lite-count", type=int, default=0) + + sk = sub.add_parser("slow-skip", help="the Cost=Slow classes a pull request run leaves out (workflow step)") + sk.add_argument("--suite", required=True, choices=sorted(SUITE_DIRS)) + sk.add_argument("--event", required=True) + sk.add_argument("--base-ref", default="", help="the pull request's base branch (github.base_ref); main skips nothing") + sk.add_argument("--repo", default="") + sk.add_argument("--pr", default="") + sk.add_argument("--list", action="store_true", help="print every Cost=Slow class of the suite instead") + sk.add_argument("files", nargs="*", help="changed files; with --repo and --pr they come from the API instead") + + dc = sub.add_parser("decide", help="print every decision for a list of changed files") + dc.add_argument("--event", default="pull_request") + dc.add_argument("files", nargs="*") + + ms = sub.add_parser("map-select", help="which classes a pull request runs, from a class-to-file map (workflow step)") + ms.add_argument("--map", required=True, help="the test map (JSON, or .gz)") + ms.add_argument("--event", default="pull_request") + ms.add_argument("--base-ref", default="", help="the pull request's base branch (github.base_ref); main runs everything") + ms.add_argument("--base", help="the merge base; the drift is `git diff --no-renames ..`") + ms.add_argument("--drift-file", help="the drift as one path per line, instead of --base (`-` for none)") + ms.add_argument("--root", default=ROOT, help="the tree whose test classes are listed (default: this repository)") + ms.add_argument("--files-from", help="the changed files as one path per line, in place of the arguments") + ms.add_argument("--out", help="also write the selection JSON to this file (stdout keeps the notice and the JSON)") + ms.add_argument("--summary", help="also write the shadow step-summary markdown to this file") + ms.add_argument("files", nargs="*") + + mk = sub.add_parser("map-keep", help="the classes a shard keeps from a selection; FULL when it cannot be trusted (workflow step)") + mk.add_argument("--selection", required=True, help="the JSON map-select --out wrote; missing or unreadable counts as FULL") + mk.add_argument("--suite", required=True, choices=("darling", "lite")) + + sc = sub.add_parser("shadow-check", help="classes that failed but a map selection would not have run (workflow step)") + sc.add_argument("--selection", help="the JSON map-select --out wrote; missing or unreadable counts as FULL") + sc.add_argument("--reports", required=True, help="a folder with one subfolder per timing artifact") + sc.add_argument("--out", required=True, help="the one-row JSONL file to write") + sc.add_argument("--meta", default="{}", help="JSON object merged into the row (run, sha, pr...)") + sc.add_argument("--attempt", type=int, help="the run attempt; accepted for callers, but it no longer filters: " + "staleness is decided per artifact folder (the `attempt` of --meta " + "still lands in the row)") + sc.add_argument("--jobs", help="the latest attempt's jobs, one {name, conclusion} JSON object per line; " + "the row lists the failed ones") + + args = ap.parse_args(argv) + if args.replay: + misses = replay(args.corpus) + if args.map: + map_replay(load_map(args.map), args.corpus, misses_out=args.misses_out) # reports; does not fail the replay yet + return 1 if misses else 0 + if args.cmd == "map-select": + files = list(args.files) + if args.files_from: + with open(args.files_from, encoding="utf-8") as fh: + files += [ln.strip() for ln in fh if ln.strip()] + test_map = load_map(args.map) + drift: "list[str] | None" = None + if args.drift_file == "-": + drift = [] + elif args.drift_file: + with open(args.drift_file, encoding="utf-8") as fh: + drift = [ln.strip() for ln in fh if ln.strip()] + elif args.base and isinstance(test_map, dict) and isinstance(test_map.get("sha"), str): + drift = git_drift(test_map["sha"], args.base) + discovered = scan_classes(args.root) + if isinstance(test_map, dict) and isinstance(test_map.get("classes"), dict): + # The map holds the suites the nightly instruments (darling, lite). The deprecated suites in the tree + # are not in it and no shard runs them as a test-map class, so they are not "new classes". + discovered = {s: v for s, v in discovered.items() if s in test_map["classes"]} + result = map_select(test_map, files, drift, discovered, event=args.event, base_ref=args.base_ref, + **selection_args(Rules())) + if result["full"]: + print(f"::notice::test map: running everything ({result['reason']})") + print(json.dumps(result, indent=1)) + if args.out: + with open(args.out, "w", encoding="utf-8", newline="\n") as fh: + json.dump(result, fh) + if args.summary: + with open(args.summary, "w", encoding="utf-8", newline="\n") as fh: + fh.write(shadow_summary(result)) + return 0 + if args.cmd == "map-keep": + # Line 1 is `SELECTED ` (then one class name per line) or `FULL `; the exit code is always 0, so a + # caller that cannot read the answer runs everything. + try: + with open(args.selection, encoding="utf-8") as fh: + chosen = json.load(fh) + except (OSError, ValueError): + chosen = None + names, why = map_keep(chosen, args.suite) + if names is None: + print(f"FULL {why}") + else: + print(f"SELECTED {len(names)}") + print("\n".join(sorted(names))) + return 0 + if args.cmd == "shadow-check": + selection = None + if args.selection: + try: + with open(args.selection, encoding="utf-8") as fh: + selection = json.load(fh) + except (OSError, ValueError): + selection = None + try: + meta = json.loads(args.meta) + except ValueError: + meta = {} + meta = meta if isinstance(meta, dict) else {} + failed, seen, stale = failed_classes(args.reports) + row = shadow_row(selection, failed, seen, meta, stale, failed_jobs(args.jobs)) + with open(args.out, "w", encoding="utf-8", newline="\n") as fh: + fh.write(json.dumps(row, sort_keys=True) + "\n") + for m in row["misses"]: + print(f"::notice title=Shadow miss::{m['suite']} {m['class']} failed and the test map would not have run it") + print(f"shadow-check: full={row['full']} misses={len(row['misses'])} reports={row['reports']}") + return 0 + if args.cmd == "lite-scope": + mode = lite_scope(args.event, _bool(args.lite), _bool(args.core), _bool(args.root), _bool(args.reads), + _bool(args.linked)) + run = mode == "full" or (mode == "reads" and args.shard == "0") + print(f"mode={mode}") + print(f"run={'true' if run else 'false'}") + return 0 + if args.cmd == "darling-scope": + scope = darling_scope(args.event, True, args.event != "release" and _bool(args.gate), + _bool(args.full) or _bool(args.core) or _bool(args.root)) + print(f"scope={scope}") + print("filter=" + scope_filter(scope)) + return 0 + if args.cmd == "tree-scope": + scope = tree_scope(args.event, args.all_count, args.lite_count) + print(f"scope={scope}") + print("filter=" + scope_filter(scope)) + return 0 + if args.cmd == "slow-skip": + index = SlowIndex() + if args.list: + print("\n".join(sorted(index.slow[args.suite]))) + return 0 + files = args.files + if not files and args.repo and args.pr.isdigit() and args.event == "pull_request": + files = changed_files_of_pr(args.repo, args.pr) + print("\n".join(index.skip(files, args.event, args.base_ref)[args.suite])) + return 0 + if args.cmd == "decide": + print(json.dumps(decide(args.files, args.event, Rules()), indent=1)) + return 0 + ap.print_help() + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/scripts/darling-upgrade-shapes.ps1 b/.github/scripts/darling-upgrade-shapes.ps1 new file mode 100644 index 0000000000..fc3a597fa9 --- /dev/null +++ b/.github/scripts/darling-upgrade-shapes.ps1 @@ -0,0 +1,265 @@ +<# +.SYNOPSIS + Runs ONE leg of the Darling upgrade-shape check (#5627): builds an older install the way an operator would + have, then runs this checkout's install-darling.ps1 or upgrade-darling.ps1 over it and fails the leg, with a + one-line reason, when the outcome is not the expected one. + +.DESCRIPTION + Called by .github/workflows/darling-upgrade-shapes.yml, one leg per job, on an elevated hosted Windows runner. + The older install is the REAL release: its zip is downloaded, its own install-darling.ps1 runs, and its + service is started until it has extracted pg-runtime (pg-runtime.stamp), then stopped. Nothing about the + base is built by hand, because the owner of the extracted files is whatever account ran the service when it + extracted them, and that is the thing the install/upgrade pre-lock check has to judge correctly. + + Shapes (the leg name is -" }, Strings(r.GetProperty("pre"))); + Assert.Equal(new[] { "", "Fix" }, Strings(r.GetProperty("headings"))); + Assert.Equal(new[] { "label" }, Strings(r.GetProperty("fields"))); + Assert.Equal(new[] { " prose" }, Strings(r.GetProperty("paragraphs"))); + Assert.Equal(0, r.GetProperty("imgs").GetInt32()); + } +} diff --git a/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs b/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs index 7e551cdd9d..4ac92d5787 100644 --- a/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs +++ b/Darling/Darling.Tests/AlertHistoryDetailLabelsTests.cs @@ -45,7 +45,7 @@ public sealed class AlertHistoryDetailLabelsTests "const parse = new Function(src.slice(start, end) + '\\nreturn parseDetailFields;')();" + "console.log(JSON.stringify(parse(JSON.parse(process.argv[2]).join('\\n'))));"; - /// Runs the shipped parser over the lines, or returns false when Node is not installed. + /// Runs the shipped parser over the lines. When Node is not installed, the test is reported as skipped. private static bool TryParse(string[] lines, out List<(string? Label, string Value)> fields) { fields = new List<(string?, string)>(); @@ -68,6 +68,7 @@ private static bool TryParse(string[] lines, out List<(string? Label, string Val } catch (Win32Exception) { + Assert.Skip("Node is not installed, so the shipped page script cannot be run."); return false; } diff --git a/Darling/Darling.Tests/AlertHistoryDismissBehaviourTests.cs b/Darling/Darling.Tests/AlertHistoryDismissBehaviourTests.cs new file mode 100644 index 0000000000..fc6072e0df --- /dev/null +++ b/Darling/Darling.Tests/AlertHistoryDismissBehaviourTests.cs @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// Dismiss Selected and Dismiss All on the web Alert History page, from the shipped alerts.js run under +/// Node (alert-history-dismiss-harness.mjs) against a recording fetch: the controls exist only for a seat +/// that can edit, the right keys are posted to /api/alert-history/dismiss, the checked set survives a +/// rebuild, a refusal is shown and never reported as success, and the reply's counts are summarised. +/// +public sealed class AlertHistoryDismissBehaviourTests +{ + private static JsonElement Run(string scenario) + { + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "alert-history-dismiss-harness.mjs")); + psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js")); + psi.ArgumentList.Add(scenario); + Process proc; + try + { + proc = Process.Start(psi)!; + } + catch (Win32Exception) + { + Assert.Skip("Node is not installed, so the shipped page script cannot be run."); + return default; + } + + using (proc) + { + var error = proc.StandardError.ReadToEndAsync(); + var output = proc.StandardOutput.ReadToEnd().Trim(); + if (!proc.WaitForExit(20000)) + { + proc.Kill(entireProcessTree: true); + Assert.Fail("the Alert History dismiss harness did not finish in 20 s for scenario " + scenario); + } + + Assert.True(proc.ExitCode == 0, "the Alert History dismiss harness failed for scenario " + scenario + ": " + error.Result); + using var doc = JsonDocument.Parse(output); + return doc.RootElement.Clone(); + } + } + + private static string[] Metrics(JsonElement post) => + post.GetProperty("req").GetProperty("alerts").EnumerateArray().Select(a => a.GetProperty("metric_name").GetString()!).ToArray(); + + [Fact] + public void ASeatThatCannotEdit_SeesNoCheckboxesAndNoDismissButtons() + { + var r = Run("readOnly"); + Assert.Equal(0, r.GetProperty("checkboxes").GetInt32()); + Assert.False(r.GetProperty("hasSelected").GetBoolean()); + Assert.False(r.GetProperty("hasAll").GetBoolean()); + } + + [Fact] + public void DismissSelected_PostsTheCheckedRowsKeys_AndRefreshesTheList() + { + var r = Run("selected"); + Assert.Equal(3, r.GetProperty("boxes").GetInt32()); + Assert.True(r.GetProperty("selectedDisabledBefore").GetBoolean()); + var post = Assert.Single(r.GetProperty("posts").EnumerateArray()); + Assert.Equal("/api/alert-history/dismiss", post.GetProperty("url").GetString()); + Assert.Equal("application/json", post.GetProperty("contentType").GetString()); + Assert.Equal(new[] { "High CPU 1", "High CPU 3" }, Metrics(post)); + var first = post.GetProperty("req").GetProperty("alerts")[0]; + Assert.Equal(1, first.GetProperty("server_id").GetInt32()); + Assert.Equal("2026-01-01T11:59:00.000Z", first.GetProperty("alert_time").GetString()); + Assert.Equal("Dismissed 2 of 2", r.GetProperty("status").GetString()); + Assert.Equal(1, r.GetProperty("rowsAfter").GetInt32()); + Assert.Equal(2, r.GetProperty("reads").GetInt32()); + } + + [Fact] + public void WithShowDismissedOn_ADismissedRowStaysAndSaysDismissed() + { + var r = Run("showDismissed"); + Assert.Equal(2, r.GetProperty("rows").GetInt32()); + Assert.True(r.GetProperty("dismissedWord").GetBoolean()); + Assert.Equal(0, r.GetProperty("firstHasBox").GetInt32()); + } + + [Fact] + public void DismissAll_PostsEveryLiveRowTheFiltersShow() + { + var r = Run("all"); + var filtered = Assert.Single(r.GetProperty("filtered").EnumerateArray()); + Assert.Equal(new[] { 2 }, filtered.EnumerateArray().Select(e => e.GetInt32()).ToArray()); + var post = Assert.Single(r.GetProperty("all").EnumerateArray()); + Assert.Equal("/api/alert-history/dismiss", post.GetProperty("url").GetString()); + Assert.Equal(3, post.GetProperty("keys").GetArrayLength()); + } + + [Fact] + public void ALongList_GoesInRequestsOfAtMostTheRoutesCap_AndTheCountsAreAddedUp() + { + var r = Run("chunks"); + Assert.Equal(new[] { 1000, 500 }, r.GetProperty("sizes").EnumerateArray().Select(e => e.GetInt32()).ToArray()); + Assert.Equal("Dismissed 1500 of 1500", r.GetProperty("status").GetString()); + } + + [Fact] + public void TheCheckedSet_SurvivesARebuild_AndClearsWhenAFilterChanges() + { + var r = Run("survives"); + Assert.Equal(new[] { false, false, true }, r.GetProperty("afterPoll").EnumerateArray().Select(e => e.GetBoolean()).ToArray()); + Assert.Equal(new[] { false, false, true }, r.GetProperty("afterReopen").EnumerateArray().Select(e => e.GetBoolean()).ToArray()); + Assert.Equal("Dismiss Selected (1)", r.GetProperty("label").GetString()); + Assert.DoesNotContain(true, r.GetProperty("afterFilter").EnumerateArray().Select(e => e.GetBoolean())); + } + + [Fact] + public void ARefusal_ShowsItsText_NeverClaimsSuccess_AndKeepsTheSelection() + { + var r = Run("refused"); + Assert.Equal("This seat is read-only.", r.GetProperty("status").GetString()); + Assert.Equal(2, r.GetProperty("rows").GetInt32()); + Assert.True(r.GetProperty("stillChecked").GetBoolean()); + var expired = r.GetProperty("status401").GetString()!; + Assert.DoesNotContain("Dismissed", expired); + Assert.Contains("Session expired", expired); + } + + [Fact] + public void DismissAllPrompt_SaysStore_AndWhenThePageIsTruncatedSaysTheRestStayLive() + { + var r = Run("truncatedPrompt"); + var full = r.GetProperty("fullAsk").GetString()!; + Assert.Contains("remain in the store", full); + Assert.DoesNotContain("database", full); + Assert.DoesNotContain("More alerts exist", full); + var truncated = r.GetProperty("truncatedAsk").GetString()!; + Assert.Contains("remain in the store", truncated); + Assert.Contains("More alerts exist than the 2 listed", truncated); + Assert.Contains("the rest stay live", truncated); + } + + [Fact] + public void CopyRowAndCopyTable_LeaveOutTheSelectColumn_ButKeepMute() + { + var r = Run("copy"); + var all = r.GetProperty("copyAll").GetString()!.Split('\n'); + var header = all[0].Split('\t'); + Assert.DoesNotContain("Select", header); + Assert.Contains("Mute", header); + var row = r.GetProperty("copyRow").GetString()!.Split('\t'); + Assert.Equal(header.Length, row.Length); + Assert.Equal(header.Length, all[1].Split('\t').Length); + Assert.Equal(header.Length + 1, r.GetProperty("cells").GetInt32()); + } + + [Fact] + public void AFailedPollRead_KeepsTheCheckedSet() + { + var r = Run("failedPoll"); + Assert.Equal("Dismiss Selected (1)", r.GetProperty("labelAfterFailedRead").GetString()); + Assert.Equal(new[] { false, true }, r.GetProperty("afterGoodRead").EnumerateArray().Select(e => e.GetBoolean()).ToArray()); + Assert.Equal("Dismiss Selected (1)", r.GetProperty("labelAfterGoodRead").GetString()); + } + + [Fact] + public void ABoxTickedWhileADismissIsInFlight_IsNotClearedAndNotSent() + { + var r = Run("tickedInFlight"); + var sent = Assert.Single(r.GetProperty("sent").EnumerateArray()); + Assert.Equal(new[] { "High CPU 1" }, sent.EnumerateArray().Select(e => e.GetString()!).ToArray()); + Assert.Equal("Dismiss Selected (1)", r.GetProperty("label").GetString()); + Assert.Equal(new[] { false, true }, r.GetProperty("checked").EnumerateArray().Select(e => e.GetBoolean()).ToArray()); + } + + [Fact] + public void TheStatusLine_SaysHowManyWereDismissedAndHowManyWereAlreadyGone() + { + var r = Run("counts"); + Assert.Equal("Dismissed 1 of 4; 3 were already gone", r.GetProperty("status").GetString()); + } + + [Fact] + public void ThePageSendsOnlyTheRoutesKeysThroughTheSharedWriteHelper() + { + var page = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "alerts.js").ReplaceLineEndings("\n"); + Assert.Contains("apiSend(\"POST\", \"/api/alert-history/dismiss\"", page); + Assert.Contains("const DISMISS_CHUNK = 1000;", page); + Assert.Contains("\nconst selected = new Map();", page); + } +} diff --git a/Darling/Darling.Tests/AlertHistoryDismissTests.cs b/Darling/Darling.Tests/AlertHistoryDismissTests.cs new file mode 100644 index 0000000000..c1a747e45b --- /dev/null +++ b/Darling/Darling.Tests/AlertHistoryDismissTests.cs @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Hosting; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// The web Alert History dismiss (#4843): the pure body parser, the role grant that lets the web host's +/// viewer identity write it, and the route itself. The live half connects AS a role holding exactly the +/// managed viewer grant on config_alert_log (the statement is lifted out of the managed +/// provisioning SQL, so a drift in it changes what these tests run), never as the owner. +/// +[Collection("live-postgres")] +public sealed class AlertHistoryDismissTests +{ + private static readonly string RunSuffix = Guid.NewGuid().ToString("N")[..8]; + private static readonly string DismissRole = "dismiss_only_" + RunSuffix; + private const string RolePassword = "DismissTestPw0123456789abcdef01"; + + private static string Cs() + { + var cs = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to run the alert dismiss live tests."); + return cs!; + } + + /* ═══════════════════════════ the pure parser ═══════════════════════════ */ + + [Fact] + public void ParseDismissBody_DedupesAndKeepsFirstSeenOrder() + { + var ok = DarlingWebEndpoints.ParseDismissBody( + """{"alerts":[{"alert_time":"2026-10-04T10:00:00","server_id":1,"metric_name":"High CPU"},{"alert_time":"2026-10-04T10:00:00Z","server_id":1,"metric_name":"High CPU"},{"alert_time":"2026-10-04T11:00:00","server_id":2,"metric_name":"Blocking"}]}""", + out var keys, out var refusal); + + Assert.True(ok, refusal); + Assert.Equal(2, keys.Count); + Assert.Equal(1, keys[0].ServerId); + Assert.Equal(2, keys[1].ServerId); + Assert.Equal(DateTimeKind.Unspecified, keys[0].AlertTime.Kind); + } + + [Theory] + [InlineData("not json")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("""{"alerts":[]}""")] + [InlineData("""{"alerts":"x"}""")] + [InlineData("""{"alerts":[{"alert_time":"2026-10-04T10:00:00","server_id":"1","metric_name":"m"}]}""")] + [InlineData("""{"alerts":[{"alert_time":"nope","server_id":1,"metric_name":"m"}]}""")] + [InlineData("""{"alerts":[{"alert_time":"2026-10-04T10:00:00","server_id":1,"metric_name":""}]}""")] + [InlineData("""{"alerts":[{"alert_time":"2026-10-04T10:00:00","server_id":1}]}""")] + [InlineData("""{"alerts":[7]}""")] + [InlineData("""{"alerts":[],"alerts":[]}""")] + public void ParseDismissBody_RefusesABadBody(string body) + { + Assert.False(DarlingWebEndpoints.ParseDismissBody(body, out _, out var refusal)); + Assert.False(string.IsNullOrEmpty(refusal)); + } + + [Fact] + public void ParseDismissBody_CapsTheKeyCount() + { + string Body(int n) => "{\"alerts\":[" + string.Join(",", Enumerable.Range(0, n) + .Select(i => $"{{\"alert_time\":\"2026-10-04T10:00:00\",\"server_id\":{i},\"metric_name\":\"m\"}}")) + "]}"; + + Assert.True(DarlingWebEndpoints.ParseDismissBody(Body(DarlingWebEndpoints.MaxDismissKeys), out var keys, out _)); + Assert.Equal(DarlingWebEndpoints.MaxDismissKeys, keys.Count); + Assert.False(DarlingWebEndpoints.ParseDismissBody(Body(DarlingWebEndpoints.MaxDismissKeys + 1), out _, out _)); + } + + /* ═══════════════════════════ the write gate ═══════════════════════════ */ + + [Theory] + [InlineData(true, "POST", true)] + [InlineData(false, "POST", false)] + [InlineData(false, "PUT", false)] + [InlineData(false, "DELETE", false)] + public void TheWriteGate_RefusesAReadOnlySeat_OnTheDismissRoute(bool canEdit, string method, bool expected) => + Assert.Equal(expected, DarlingWebSeat.IsRequestAllowed( + new DarlingWebSeat("who", canEdit), method, "/api/alert-history/dismiss")); + + /* ═══════════════════════════ the grant text ═══════════════════════════ */ + + [Fact] + public void ManagedProvisioning_GrantsViewerExactlyTheDismissedColumn_AndMcpNothing() + { + var sql = DarlingManagedRoles.BuildProvisioningSql( + ProvisioningTestSecrets.Admin, ProvisioningTestSecrets.Viewer, ProvisioningTestSecrets.Mcp, 15); + + Assert.Contains("GRANT UPDATE (dismissed) ON config.config_alert_log TO viewer;", sql, StringComparison.Ordinal); + Assert.DoesNotContain("config.config_alert_log TO mcp", sql, StringComparison.Ordinal); + + /* No table-level UPDATE, INSERT or DELETE on the alert log for viewer, in any spelling. */ + var tableLevel = Regex.Matches( + Regex.Replace(sql, @"(?m)^\s*--.*$", ""), + @"GRANT\s+(?[^;(]*?)\s+ON\s+config\.config_alert_log\s+TO\s+[^;]*viewer[^;]*;"); + Assert.Empty(tableLevel); + } + + /* ═══════════════════════════ live: AS the grant-only role ═══════════════════════════ */ + + [Fact] + public async Task TheColumnGrant_LetsTheRoleDismiss_AndNothingElse_AndLeavesTheReadOnlyProbeFalse() + { + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(Cs(), ct); + await using (var owner = new NpgsqlConnection(scratch.ConnectionString)) + { + await owner.OpenAsync(ct); + await PgMigrations.MigrateAsync(owner, ct); + await CreateDismissOnlyRoleAsync(owner, ct); + await Exec(owner, SeedSql, ct); + } + + var bodySucceeded = false; + try + { + await using var role = new NpgsqlConnection(RoleConnectionString(scratch.ConnectionString)); + await role.OpenAsync(ct); + + /* Negative control: the same role, same statement, BEFORE the shipped grant, is refused. This is what + makes the success below attributable to the grant and not to a role that could always write. */ + var dismissKey = new AlertDismissKey(new DateTime(2026, 10, 4, 10, 0, 0), -7001, "High CPU"); + await using (var before = AlertDismissStore.CreateDismissCommand( + NpgsqlDataSource.Create(RoleConnectionString(scratch.ConnectionString)), new[] { dismissKey }, 30)) + { + var refused = await Assert.ThrowsAsync(() => before.ExecuteNonQueryAsync(ct)); + Assert.Equal("42501", refused.SqlState); + } + + await using (var owner = new NpgsqlConnection(scratch.ConnectionString)) + { + await owner.OpenAsync(ct); + await GrantDismissedColumnAsync(owner, ct); + } + + /* The WPF read-only probe, verbatim, as the grant-only role: a column grant is not a table grant. */ + Assert.False(await Scalar(role, "SELECT has_table_privilege('config_alert_log', 'UPDATE')", ct)); + Assert.True(await Scalar(role, "SELECT has_column_privilege('config_alert_log', 'dismissed', 'UPDATE')", ct)); + Assert.False(await Scalar(role, "SELECT has_column_privilege('config_alert_log', 'muted', 'UPDATE')", ct)); + + /* The shared write core, as that role. */ + await using (var command = AlertDismissStore.CreateDismissCommand( + NpgsqlDataSource.Create(RoleConnectionString(scratch.ConnectionString)), new[] { dismissKey }, 30)) + { + Assert.Equal(1, await command.ExecuteNonQueryAsync(ct)); + } + + /* Every other column, INSERT and DELETE: 42501. */ + foreach (var statement in new[] + { + "UPDATE config_alert_log SET muted = TRUE", + "UPDATE config_alert_log SET metric_name = 'x'", + "UPDATE config_alert_log SET dismissed = FALSE, send_error = 'x'", + "INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value) VALUES (now(), -1, 's', 'm', 1, 1)", + "DELETE FROM config_alert_log", + }) + { + var ex = await Assert.ThrowsAsync(() => Exec(role, statement, ct)); + Assert.Equal("42501", ex.SqlState); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, DropRoleAsync); + } + } + + [Fact] + public async Task TheRoute_Answers200_400_415_AsTheGrantOnlyRole_AndReportsUnknownAndAlreadyDismissed() + { + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(Cs(), ct); + await using (var owner = new NpgsqlConnection(scratch.ConnectionString)) + { + await owner.OpenAsync(ct); + await PgMigrations.MigrateAsync(owner, ct); + await ProvisionDismissOnlyRoleAsync(owner, ct); + await Exec(owner, SeedSql, ct); + } + + var bodySucceeded = false; + try + { + await using var ds = NpgsqlDataSource.Create(RoleConnectionString(scratch.ConnectionString)); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + ContentRootPath = Path.Combine(RepoFile.Root, "Darling", "PerformanceMonitor.Darling.Service"), + WebRootPath = "wwwroot", + }); + builder.WebHost.UseTestServer(); + builder.Logging.ClearProviders(); + builder.Services.AddSingleton(ds); + await using var app = builder.Build(); + DarlingWebEndpoints.MapAll(app, ds, new CollectorRuntimeState(), new CapturingTestLogger()); + await app.StartAsync(ct); + using var server = app.GetTestServer(); + + async Task<(int Status, string Body)> Post(string? contentType, string body) + { + var context = await server.SendAsync(request => + { + request.Request.Method = "POST"; + request.Request.Path = "/api/alert-history/dismiss"; + request.Request.Headers.Host = "localhost"; + if (contentType is not null) + { + request.Request.ContentType = contentType; + } + + request.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body)); + }); + using var reader = new StreamReader(context.Response.Body); + return (context.Response.StatusCode, await reader.ReadToEndAsync(ct)); + } + + static string Alert(string time, int server, string metric) => + $"{{\"alert_time\":\"{time}\",\"server_id\":{server},\"metric_name\":\"{metric}\"}}"; + + /* 415 and 400 never reach the store. */ + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, (await Post("text/plain", "{}")).Status); + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, (await Post(null, "{}")).Status); + Assert.Equal(StatusCodes.Status400BadRequest, (await Post("application/json", "nope")).Status); + Assert.Equal(StatusCodes.Status400BadRequest, (await Post("application/json", "{\"alerts\":[]}")).Status); + Assert.Equal(StatusCodes.Status400BadRequest, (await Post("application/json", "{\"alerts\":[],\"alerts\":[]}")).Status); + + /* Two real rows (one duplicated), one unknown key. */ + var first = await Post("application/json; charset=utf-8", "{\"alerts\":[" + string.Join(",", + Alert("2026-10-04T10:00:00", -7001, "High CPU"), + Alert("2026-10-04T10:00:00", -7001, "High CPU"), + Alert("2026-10-04T10:05:00", -7001, "Blocking"), + Alert("2026-10-04T10:00:00", -7999, "No Such")) + "]}"); + Assert.Equal(StatusCodes.Status200OK, first.Status); + using (var doc = JsonDocument.Parse(first.Body)) + { + var root = doc.RootElement; + Assert.Equal(3, root.GetProperty("requested").GetInt32()); + Assert.Equal(2, root.GetProperty("dismissed").GetInt32()); + Assert.Equal(0, root.GetProperty("already_dismissed").GetInt32()); + Assert.Equal(1, root.GetProperty("unknown").GetInt32()); + } + + /* Repeating it dismisses nothing new: both rows are already dismissed. */ + var again = await Post("application/json", "{\"alerts\":[" + Alert("2026-10-04T10:00:00", -7001, "High CPU") + "]}"); + using (var doc = JsonDocument.Parse(again.Body)) + { + Assert.Equal(0, doc.RootElement.GetProperty("dismissed").GetInt32()); + Assert.Equal(1, doc.RootElement.GetProperty("already_dismissed").GetInt32()); + } + + await using var verify = new NpgsqlConnection(scratch.ConnectionString); + await verify.OpenAsync(ct); + Assert.Equal(2L, await Scalar(verify, "SELECT count(*) FROM config_alert_log WHERE server_id = -7001 AND dismissed", ct)); + Assert.Equal(1L, await Scalar(verify, "SELECT count(*) FROM config_alert_log WHERE server_id = -7001 AND NOT dismissed", ct)); + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, DropRoleAsync); + } + } + + /* ═══════════════════════════ helpers ═══════════════════════════ */ + + private const string SeedSql = @" +INSERT INTO config_alert_log (alert_time, server_id, server_name, metric_name, current_value, threshold_value) VALUES + ('2026-10-04 10:00:00', -7001, 'srv', 'High CPU', 90, 80), + ('2026-10-04 10:05:00', -7001, 'srv', 'Blocking', 5, 1), + ('2026-10-04 10:10:00', -7001, 'srv', 'Deadlocks', 2, 1)"; + + private static Task CreateDismissOnlyRoleAsync(NpgsqlConnection owner, CancellationToken ct) => + Exec(owner, $@" +CREATE ROLE {DismissRole} LOGIN NOSUPERUSER PASSWORD '{RolePassword}'; +GRANT USAGE ON SCHEMA config TO {DismissRole}; +GRANT SELECT ON config.config_alert_log TO {DismissRole};", ct); + + /// Gives the role ONLY the managed viewer's alert-log grant, lifted from the managed provisioning SQL + /// so the test runs the shipped statement. + private static async Task GrantDismissedColumnAsync(NpgsqlConnection owner, CancellationToken ct) + { + var managed = DarlingManagedRoles.BuildProvisioningSql( + ProvisioningTestSecrets.Admin, ProvisioningTestSecrets.Viewer, ProvisioningTestSecrets.Mcp, 15); + var grant = Regex.Match(managed, @"GRANT UPDATE \(dismissed\) ON config\.config_alert_log TO viewer;"); + Assert.True(grant.Success, "the managed provisioning SQL no longer grants viewer the dismissed column"); + + await Exec(owner, grant.Value.Replace(" TO viewer;", " TO " + DismissRole + ";", StringComparison.Ordinal), ct); + } + + private static async Task ProvisionDismissOnlyRoleAsync(NpgsqlConnection owner, CancellationToken ct) + { + await CreateDismissOnlyRoleAsync(owner, ct); + await GrantDismissedColumnAsync(owner, ct); + } + + private static Task DropRoleAsync(NpgsqlConnection owner, CancellationToken ct) => + Exec(owner, $"DROP OWNED BY {DismissRole}; DROP ROLE IF EXISTS {DismissRole}", ct); + + private static string RoleConnectionString(string baseConnectionString) => + new NpgsqlConnectionStringBuilder(baseConnectionString) + { + Username = DismissRole, + Password = RolePassword, + SearchPath = "config,public", + Pooling = false, + }.ConnectionString; + + private static async Task Exec(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task Scalar(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + return (T)(await command.ExecuteScalarAsync(ct))!; + } +} diff --git a/Darling/Darling.Tests/AlertHistoryLazyRenderTests.cs b/Darling/Darling.Tests/AlertHistoryLazyRenderTests.cs index 1ce730384e..9851dde10e 100644 --- a/Darling/Darling.Tests/AlertHistoryLazyRenderTests.cs +++ b/Darling/Darling.Tests/AlertHistoryLazyRenderTests.cs @@ -66,10 +66,17 @@ pins against regressing. */ Assert.Contains("if (built || !node.open) return;", detailCell, StringComparison.Ordinal); Assert.Contains("mount(placeholder, detailBody(a));", detailCell, StringComparison.Ordinal); + /* #5241: an Analysis row's advice is fetched here, in the toggle handler, once per opened row - never by the + list read, and never for a row nobody opened. */ + Assert.Contains("if (isAnalysisAlert(a)) {", detailCell, StringComparison.Ordinal); + Assert.Contains("await alertAdvice(a)", detailCell, StringComparison.Ordinal); + Assert.Contains("mount(placeholder, detailBody(a, items));", detailCell, StringComparison.Ordinal); + Assert.DoesNotContain("include_details", js, StringComparison.Ordinal); + /* The control: detailBody is where that work actually happens, so the absence above is real and not a matcher for text that was never going to appear anywhere in this file. Same field/error shape as the pre-#4194 eager version - only when it runs has changed. */ - var detailBody = ExtractFunction(js, "function detailBody(a) {"); + var detailBody = ExtractFunction(js, "function detailBody(a, details) {"); Assert.Contains("parseDetailFields(", detailBody, StringComparison.Ordinal); Assert.Contains("fieldRow", detailBody, StringComparison.Ordinal); Assert.Contains("detail-fields", detailBody, StringComparison.Ordinal); @@ -117,7 +124,10 @@ public void SixtySecondPoll_NeverShowsTheLoadingStrip_OnlyTheFirstMountDoes() must not call it - the previous table stays exactly as it is until the new page is ready. */ var js = AlertsJs; var loadingStripUses = Regex.Matches(js, Regex.Escape("loadingStrip(")).Count; - Assert.Equal(1, loadingStripUses); + /* The first mount in renderAlerts, and (#5241) the one in an opened Analysis row's own expansion while its advice + loads; neither is on the poll path. */ + Assert.Equal(2, loadingStripUses); + Assert.Contains("mount(placeholder, [loadingStrip()]);", ExtractFunction(js, "function detailCell(a) {"), StringComparison.Ordinal); var refreshAlerts = ExtractFunction(js, "async function refreshAlerts(state) {"); Assert.DoesNotContain("loadingStrip", refreshAlerts, StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/AlertHistoryRangePageTests.cs b/Darling/Darling.Tests/AlertHistoryRangePageTests.cs new file mode 100644 index 0000000000..f1d05c2328 --- /dev/null +++ b/Darling/Darling.Tests/AlertHistoryRangePageTests.cs @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// Alert History's range, row-limit, server and Show dismissed choices (#4843): source pins for what the page asks +/// get_alert_history for, and a run of the shipped page under Node (alert-history-range-harness.mjs) for what +/// it does with each choice. Node is skipped when it is not installed. +/// +public sealed class AlertHistoryRangePageTests +{ + private static string Page() => + ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "alerts.js").ReplaceLineEndings("\n"); + + [Fact] + public void ThePageReadsGetAlertHistoryWithTheFourChoicesAndNeverAFixedWindow() + { + var page = Page(); + Assert.DoesNotContain("hours_back: 24", page); + Assert.DoesNotContain("limit: 200 }", page); + Assert.Contains("readTool(\"get_alert_history\", req.params)", page); + // #5562: the range is the shared picker's spec, read as of each read; a finished range also sends its end. + Assert.Contains("windowResultOfSpec(choices.spec);", page); + Assert.Contains("hours_back: w.hours", page); + Assert.Contains("as_of: w.asOf", page); + // Review r1 H2: the reply is trimmed to the range start, since the read takes whole hours. + Assert.Contains("trimAlertsToStart(res.data.alerts || [], req.startMs)", page); + Assert.Contains("limit: choices.limit", page); + Assert.Contains("server_name: choices.server || null", page); + Assert.Contains("include_dismissed: choices.dismissed ? \"true\" : null", page); + Assert.Contains("readTool(\"list_servers\", {})", page); + Assert.Contains("res.data.truncated === true", page); + } + + [Fact] + public void NoWindowOrLimitIsOfferedThatTheToolOrTheDispatchLayerRefuses() + { + var page = Page(); + // #5562 R8: no hand-listed windows and no "All". The picker takes its reach from the catalog's max_hours for the read, which is + // the 2160 hours (90 days) the read's validator accepts, so a longer range is greyed out with the reason and never clamped. That + // is the alert table's retention (AlertHistoryRetentionDays, DarlingRetention.cs), the longest choice the Viewer offers (R8, L4b). + Assert.DoesNotContain("WINDOW_CHOICES", page); + Assert.Contains("read: \"get_alert_history\"", page); + Assert.Contains("pageRangePicker({", page); + Assert.Equal(24 * PerformanceMonitor.Darling.Storage.DarlingRetentionHorizons.AlertHistoryRetentionDays, PerformanceMonitor.Darling.Service.WebReadReach.All["get_alert_history"].MaxHours); + Assert.Equal(90, PerformanceMonitor.Darling.Storage.DarlingRetentionHorizons.AlertHistoryRetentionDays); + + var limits = System.Text.RegularExpressions.Regex.Match(page, "const LIMIT_CHOICES = \\[([0-9, ]+)\\]").Groups[1].Value + .Split(',').Select(s => int.Parse(s.Trim())).ToList(); + Assert.Equal(new[] { 200, 500, 1000 }, limits); + Assert.True(limits.Max() <= 1000, "the dispatch layer clamps a row count to 1000"); + } + + [Fact] + public void TheChoicesLiveAtModuleScopeAndTheDismissRouteIsNotTheDesktopOne() + { + var page = Page(); + Assert.Contains("\nconst choices = {", page); + Assert.DoesNotContain("/api/alerts/dismiss", page); + } + + private static JsonElement Run(string scenario, string? timeZone = null) + { + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "alert-history-range-harness.mjs")); + psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js")); + psi.ArgumentList.Add(scenario); + if (timeZone is not null) + { + psi.ArgumentList.Add(timeZone); + } + + Process proc; + try + { + proc = Process.Start(psi)!; + } + catch (Win32Exception) + { + Assert.Skip("Node is not installed, so the shipped page script cannot be run."); + return default; + } + + using (proc) + { + var error = proc.StandardError.ReadToEndAsync(); + var output = proc.StandardOutput.ReadToEnd().Trim(); + if (!proc.WaitForExit(20000)) + { + proc.Kill(entireProcessTree: true); + Assert.Fail("the Alert History harness did not finish in 20 s for scenario " + scenario); + } + + Assert.True(proc.ExitCode == 0, "the Alert History harness failed for scenario " + scenario + ": " + error.Result); + using var doc = JsonDocument.Parse(output); + return doc.RootElement.Clone(); + } + } + + private static string Get(JsonElement read, string name) => + read.TryGetProperty(name, out var v) ? v.GetString()! : "(absent)"; + + [Fact] + public void EachChoiceReachesTheRead() + { + var r = Run("reads"); + var first = r.GetProperty("first"); + Assert.Equal("24", Get(first, "hours_back")); + Assert.Equal("200", Get(first, "limit")); + Assert.Equal("(absent)", Get(first, "server_name")); + Assert.Equal("(absent)", Get(first, "include_dismissed")); + + Assert.Equal("168", Get(r.GetProperty("window"), "hours_back")); + Assert.Equal("1000", Get(r.GetProperty("limit"), "limit")); + Assert.Equal("srv-b", Get(r.GetProperty("server"), "server_name")); + var dismissed = r.GetProperty("dismissed"); + Assert.Equal("true", Get(dismissed, "include_dismissed")); + Assert.Equal("168", Get(dismissed, "hours_back")); + Assert.Equal("1000", Get(dismissed, "limit")); + Assert.Equal("srv-b", Get(dismissed, "server_name")); + + // The popup offers every preset and calendar period within the catalog's 90 days; the longer ones are greyed out, never clamped. + Assert.Equal("Past 5 minutes,Past 15 minutes,Past 30 minutes,Past hour,Past 4 hours,Past day,Past 2 days,Past week,Past 30 days,Past 90 days,Today,Yesterday,Week to Date,Previous Week,Month to Date,Previous Month", + string.Join(",", r.GetProperty("windowOptions").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal("Year to Date,Previous Year", + string.Join(",", r.GetProperty("windowGreyed").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal("This page's reads reach at most 90 days back.", r.GetProperty("refused").GetString()); + // A finished range sends its end as as_of; a shorter-than-an-hour range reads the whole hour. + Assert.EndsWith("Z", Get(r.GetProperty("finished"), "as_of")); + Assert.Equal("1", Get(r.GetProperty("thirtyMinutes"), "hours_back")); + Assert.Equal("200,500,1000", string.Join(",", r.GetProperty("limitOptions").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal(",srv-a,srv-b", string.Join(",", r.GetProperty("serverOptions").EnumerateArray().Select(e => e.GetString()))); + } + + [Fact] + public void TheChoicesSurviveThePollAndAVisitElsewhere() + { + var r = Run("survive"); + foreach (var step in new[] { "poll", "back" }) + { + var reads = r.GetProperty(step).EnumerateArray().ToList(); + Assert.Single(reads); + Assert.Equal("4", Get(reads[0], "hours_back")); + Assert.Equal("500", Get(reads[0], "limit")); + Assert.Equal("srv-a", Get(reads[0], "server_name")); + Assert.Equal("true", Get(reads[0], "include_dismissed")); + } + + var shown = r.GetProperty("shown"); + Assert.Equal("Past 4 hours", shown.GetProperty("window").GetString()); + Assert.Equal("500", shown.GetProperty("limit").GetString()); + Assert.Equal("srv-a", shown.GetProperty("server").GetString()); + Assert.True(shown.GetProperty("dismissed").GetBoolean()); + } + + [Fact] + public void ATruncatedReplySaysMoreAlertsExistAndAClearOneDoesNot() + { + var r = Run("truncated"); + Assert.True(r.GetProperty("truncated").GetBoolean()); + Assert.False(r.GetProperty("notTruncated").GetBoolean()); + Assert.True(r.GetProperty("pollTruncated").GetBoolean()); + Assert.False(r.GetProperty("pollCleared").GetBoolean()); + } + + [Fact] + public void DismissedRowsAreMarkedOnFirstDrawAndOnLaterReconciles() + { + var r = Run("dismissed"); + Assert.Equal(new[] { "", "alert-dismissed" }, r.GetProperty("classes").EnumerateArray().Select(e => e.GetString()).ToArray()); + Assert.Equal(new[] { "alert-dismissed", "", "alert-dismissed" }, r.GetProperty("afterPoll").EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + [Fact] + public void AKeptRowTakesItsDismissedMarkingFromALaterPoll() + { + var r = Run("dismissedLater"); + string[] Strings(string n) => r.GetProperty(n).EnumerateArray().Select(e => e.GetString() ?? "").ToArray(); + Assert.Equal(new[] { "", "" }, Strings("before")); + Assert.Equal(new[] { "", "alert-dismissed" }, Strings("after")); + Assert.Equal(new[] { "", "Dismissed" }, Strings("afterTitle")); + Assert.Equal(new[] { false, true }, r.GetProperty("afterStatus").EnumerateArray().Select(e => e.GetBoolean()).ToArray()); + Assert.Equal("High CPU 1,High CPU 2", string.Join(",", Strings("order"))); + Assert.Equal(new[] { "", "" }, Strings("restored")); + } + + [Fact] + public void TwoServersThatShareADisplayNameKeepBothRows() + { + Assert.Equal(2, Run("sharedName").GetProperty("rows").GetInt32()); + } + + [Fact] + public void TheRangePickerTakesItsReachFromTheCatalog_NinetyDays() + { + // #5562: the picker greys out what the read cannot reach and says why (the catalog's max_hours), so the page needs no hand-written + // tooltip; the reach is the read's own 2160 hours, the alert table's retention (L4b). + Assert.DoesNotContain("The web reads at most 7 days of alert history, so there is no All choice.", Page()); + Assert.DoesNotContain("the web read stops at the common 7 day reach", Page()); + Assert.Contains("get_alert_history reaches 2160 hours, 90 days", Page()); + } + + [Fact] + public void ANewWindowReconcilesInPlace_RowsLeaveAndNewOnesLandInOrder() + { + var r = Run("reconcile"); + Assert.Equal("High CPU 1,High CPU 3,High CPU 5", string.Join(",", r.GetProperty("before").EnumerateArray().Select(e => e.GetString()))); + Assert.Equal("High CPU 0,High CPU 1,High CPU 2,High CPU 3", string.Join(",", r.GetProperty("after").EnumerateArray().Select(e => e.GetString()))); + Assert.True(r.GetProperty("keptNode").GetBoolean(), "an unchanged row keeps its DOM node across a window change"); + Assert.Equal("High CPU 3", string.Join(",", r.GetProperty("shrunk").EnumerateArray().Select(e => e.GetString()))); + } + + // Review r2 H1: alert_time is naive UTC with no zone suffix, which the browser reads as LOCAL time unless the page parses it as UTC. The + // harness rows carry the server's real shape, and the same scenario runs in zones that are not UTC (and in UTC) because a UTC-only run + // passes the old Date.parse code. The offset check proves Node honoured the zone, so the test cannot pass by running in UTC every time. + [Theory] + [InlineData("UTC", 0)] + [InlineData("Europe/Berlin", -1)] + [InlineData("America/New_York", 1)] + [InlineData("Asia/Tokyo", -1)] + public void ARangeShorterThanTheWholeHoursItFetches_ListsOnlyTheAlertsInsideIt(string zone, int offsetSign) + { + var r = Run("trim", zone); + Assert.Equal(offsetSign, Math.Sign(r.GetProperty("tzOffsetMinutes").GetInt32())); + string[] Names(string key) => r.GetProperty(key).EnumerateArray().Select(e => e.GetString()!).ToArray(); + Assert.Equal(new[] { "High CPU 0", "High CPU 19", "High CPU 89" }, Names("day")); + Assert.Equal(new[] { "High CPU 0" }, Names("fiveMinutes")); + Assert.Equal(new[] { "High CPU 0", "High CPU 19" }, Names("thirtyMinutes")); + Assert.Equal(new[] { "High CPU 0", "High CPU 19", "High CPU 89" }, Names("fourHours")); + } + + [Fact] + public void TheMuteLinksStillRenderOnEveryRow() + { + var links = Run("mute").GetProperty("links").EnumerateArray().Select(e => e.GetString()!).ToList(); + Assert.Contains(links, l => l.StartsWith("Mute this alert #/mute-rules?server_id=1&server_name=srv-a&metric_name=", StringComparison.Ordinal)); + Assert.Contains(links, l => l.StartsWith("Mute similar #/mute-rules?metric_name=", StringComparison.Ordinal)); + } +} diff --git a/Darling/Darling.Tests/AlertHistoryRowSeverityTests.cs b/Darling/Darling.Tests/AlertHistoryRowSeverityTests.cs index ca6bfddbca..cbd7f4e18d 100644 --- a/Darling/Darling.Tests/AlertHistoryRowSeverityTests.cs +++ b/Darling/Darling.Tests/AlertHistoryRowSeverityTests.cs @@ -37,6 +37,7 @@ namespace Darling.Tests; /// tier exists, and says the right thing there; both grids' row classes reach the shared decision and no /// grid still calls the by-name predicates directly. /// +[Trait("Reads", "Lite")] public sealed class AlertHistoryRowSeverityTests { /* ─────────────────────────── the member on the wire ─────────────────────────── */ diff --git a/Darling/Darling.Tests/AlertMasterSwitchSurfaceTests.cs b/Darling/Darling.Tests/AlertMasterSwitchSurfaceTests.cs index a500937187..f3671ed2c4 100644 --- a/Darling/Darling.Tests/AlertMasterSwitchSurfaceTests.cs +++ b/Darling/Darling.Tests/AlertMasterSwitchSurfaceTests.cs @@ -42,6 +42,8 @@ namespace Darling.Tests; /// "track always, deliver conditionally" split, which predates this issue and is the idiom precedent the /// AND'd gates follow. /// +[Trait("Stage", "Guard")] +[Trait("Reads", "Lite")] public sealed class AlertMasterSwitchSurfaceTests { /* ---------------- the delivery-call census ---------------- */ diff --git a/Darling/Darling.Tests/AlertMetricClassifierTests.cs b/Darling/Darling.Tests/AlertMetricClassifierTests.cs index cbdba50baa..7291a16bd8 100644 --- a/Darling/Darling.Tests/AlertMetricClassifierTests.cs +++ b/Darling/Darling.Tests/AlertMetricClassifierTests.cs @@ -48,6 +48,7 @@ public class AlertMetricClassifierTests [InlineData("AG Sync Recovered")] [InlineData("AG Data Movement Resumed")] [InlineData("Web TLS Certificate Renewed")] // #3514 + [InlineData("MCP TLS Certificate Renewed")] // #5288 public void IsResolution_True_ForEveryResolutionNotice(string metric) { Assert.True(AlertMetricClassifier.IsResolution(metric)); @@ -77,6 +78,8 @@ into the resolution bucket by "Reconnected"/"Recovered"/"Resumed" appearing else /* #3514: the active web-TLS-certificate alert stays actionable — its "Expiring" name carries none of the resolution words, and adding "Renewed" to the vocabulary must not drag it green. */ [InlineData("Web TLS Certificate Expiring")] + /* #5288: and so does the MCP endpoint's twin. */ + [InlineData("MCP TLS Certificate Expiring")] public void IsResolution_False_ForActionableAlerts(string metric) { Assert.False(AlertMetricClassifier.IsResolution(metric)); diff --git a/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs b/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs index 977ea965ff..a5ed50f63f 100644 --- a/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs +++ b/Darling/Darling.Tests/AlertNotebookRenderBehaviourTests.cs @@ -41,6 +41,7 @@ private static bool TryRun(string scenario, out JsonElement result) } catch (Win32Exception) { + Assert.Skip("Node is not installed, so the shipped page script cannot be run."); return false; } diff --git a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs index a7ea7d7363..d7de36724b 100644 --- a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs +++ b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs @@ -307,18 +307,19 @@ public void Views_AlertModePanelCells_ScopeByTheEndpointsScopeServer_NeverByADis [Fact] public void Views_PanelOrError_SettlesExactlyOnce_SoABadPanelCellCannotHoldALimiterSlot() { - var body = CodeOf(ReadRepoFileLf(ViewsPath), "function panelOrError(p, readSet, sourceSet, scope, onSettled) {"); + var body = CodeOf(ReadRepoFileLf(ViewsPath), "function panelOrError(p, readSet, sourceSet, scope, onSettled, slot = null) {"); Assert.Contains("if (onSettled) onSettled();", body, StringComparison.Ordinal); - Assert.Contains("renderComposedPanelCard(p, scope, onSettled)", body, StringComparison.Ordinal); + Assert.Contains("renderComposedPanelCard(p, scope, onSettled, slot)", body, StringComparison.Ordinal); Assert.Contains("renderPanel(p, onSettled)", body, StringComparison.Ordinal); // Every error card is built by the settling `fail` function: the one panelErrorCard call left is inside it. Assert.Single(Regex.Matches(body, @"panelErrorCard\(")); - // The dashboard grid passes no callback, so its behaviour is the one it had. + // The dashboard grid passes no callback (its panel is only re-scoped by panelUnderScope first, which cannot + // take a limiter slot), so its behaviour is the one it had. Assert.Contains( - "panels.map((p) => panelOrError(p, readSet, sourceSet, currentScope()))", + "panels.map((p, i) => panelOrError(panelUnderScope(p, state, panelServer), readSet, sourceSet, currentScope(), undefined, i))", ReadRepoFile(ViewsPath), StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs b/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs index c603845599..51c0c30af1 100644 --- a/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs +++ b/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs @@ -260,6 +260,8 @@ public void ACollectorCostCell_ResolvesToTheFleetPartWithoutACollectorName_AndTh private static readonly Dictionary s_keyConstants = new(StringComparer.Ordinal) { ["get_database_sizes.size_note"] = "AzureSiblingDatabaseSize.RowNoteKey", + // #5249: the gated rows are built by the helper the tool calls (anonymous-object member `message`). + ["get_collection_health.message"] = "DarlingGatedCollectorRows.AppendAsync(", }; /// The text from to the next marker (or the end). diff --git a/Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs b/Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs index 0de7d2358e..253c9bd5c6 100644 --- a/Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs +++ b/Darling/Darling.Tests/AlertReadFailureSurfaceTests.cs @@ -41,6 +41,7 @@ namespace Darling.Tests; /// down), and the counts are asserted in both directions so a walk that silently stopped reaching cannot /// report clean. /// +[Trait("Reads", "Lite")] public sealed class AlertReadFailureSurfaceTests { /* ---------------- the counter's own behaviour ---------------- */ @@ -922,7 +923,7 @@ private static readonly (string Path, int Counted, int Exempt)[] s_wholeFileScop DarlingWorker. 16th exempt since #4750: EvaluateNotificationChannelsAsync's wrapper catch — the webhook channels' failure counts are read from the webhook service's memory, so there is no store read for the condition to swallow. */ - (Path.Combine("Darling", "PerformanceMonitor.Darling.Service", "DarlingSelfAlertEvaluator.cs"), 12, 17), + (Path.Combine("Darling", "PerformanceMonitor.Darling.Service", "DarlingSelfAlertEvaluator.cs"), 13, 18), }; /// @@ -967,7 +968,7 @@ private static readonly (string Path, int Counted, int Exempt)[] s_wholeFileScop /* 11th since #4215: ReadRejectedManagedConfSettingNamesAsync's catch — COUNTED, not exempt, because a RejectedValue row is judgeable evidence for the alert, not context for its text. */ private const int WorkerCountedSites = 11; - private const int WorkerExemptSites = 11; + private const int WorkerExemptSites = 12; /// /// Counted sites tree-wide. ONE numeral with several readers rather than the same number written out at @@ -1010,9 +1011,11 @@ private static readonly (string Path, int Counted, int Exempt)[] s_wholeFileScop /// shape at the store's own health. And a THIRTEENTH since #4215: the store-settings /// self-alert's rejected-verdict read, moved from exempt to counted because a RejectedValue row is one of /// the three conditions the alert fires on — losing it leaves that condition unjudgeable, and the original - /// exempt classification would let one failed read write a false "Store Settings Resolved". + /// exempt classification would let one failed read write a false "Store Settings Resolved". And a + /// THIRTY-EIGHTH since #5450: the daily retained-history audit's per-rollup read, counted because a swallowed + /// read skips that rollup for the day, and the audit is the only place the day's thin hours are ever named. /// - private const int CountedSites = 37; + private const int CountedSites = 38; /// /// Log-message fragments that identify a catch block DELIBERATELY not counted, each paired with the @@ -1029,6 +1032,7 @@ private static readonly (string Path, int Counted, int Exempt)[] s_wholeFileScop ["Store disk-pressure self-alert failed"] = "handed its evidence as parameters; the read is counted in DarlingWorker", ["Custom-alert rule-health self-alert failed"] = "handed its evidence (the report) as a parameter; the report-building read is in CustomAlertEvaluator, outside this census", ["Store runtime upgrade self-alert failed"] = "handed its evidence as parameters", + ["Collection gap at start self-alert failed"] = "handed its evidence as parameters", ["Store TimescaleDB self-alert failed"] = "handed its evidence as parameters; the version was read by the bootstrap (#3908)", /* #3816 renamed this line with the check: the same catch, one family over — the self-heal now covers every policy family, so "Compression-job health" would have named a third of what it isolates. */ @@ -1039,7 +1043,7 @@ private static readonly (string Path, int Counted, int Exempt)[] s_wholeFileScop ["Notification-channel self-alert failed"] = "reads the webhook channels' failure counts from the webhook service's memory and performs no store read at all - there is no read for this condition to be the swallowing of", ["Stale-mute self-alert failed"] = "handed its evidence (the live MuteRuleService cache) as a parameter and performs no store read at all - there is no read anywhere for this condition to be the swallowing of", ["Fleet gate self-alert failed"] = "handed its evidence (the worker's in-memory gate counts) as a parameter and performs no store read at all", - ["Web TLS certificate self-alert failed"] = "handed its evidence (the report from the web host's in-memory WebTlsCertificateState publish) as a parameter and performs no store read at all", + ["Web TLS certificate self-alert failed"] = "handed its evidence (the report from the listener host's in-memory TLS certificate state publish: WebTlsCertificateState for the web host, McpTlsCertificateState for the MCP host) as a parameter and performs no store read at all; one shared catch covers both listeners", ["Store settings self-alert failed"] = "handed its evidence as a parameter; the one store read behind it (the rejected-verdict names) is isolated in its own COUNTED catch in DarlingWorker (#4215) rather than exempted", ["Failed to record resolution"] = "an audit-row write", ["Could not record Postgres alert resolution"] = "a history write", @@ -1056,6 +1060,7 @@ private static readonly (string Path, int Counted, int Exempt)[] s_wholeFileScop ["CONVERTS the fault into the unreadable count"] = "a parse arm, not a read: the fleet-sweep rollup's store read is counted above it, and a document that does not parse becomes the rollup's own reportable unreadable count - the fault is evidence, not a swallow", ["Could not resolve Agent job names"] = "reads the monitored server's msdb through the host resolver, not the store - the Recently-failed-job precedent one seam over; the card degrades to the unresolved form whose raw marker keeps the gap visible, and the page still delivers", ["delivery stamp could not be written"] = "a write (#3580): the daily document was already delivered and process memory already gates it; the dropped stamp costs one re-announcement at the next restart and never a delivery - the stamp READ beside it is the read, and it is counted", + ["Store statement history is still failing"] = "the Debug repeat of a telemetry write sweep (#5097); the first failure warns, no alert is judged on its result", ["Read-latency flush failed"] = "the read-latency histogram flush writes instrumentation; losing an hour's rows can't hide an alert condition", }; @@ -1241,8 +1246,11 @@ WebTlsCertificateState report the web host publishes - there is no store read to flush's catch, a telemetry write whose loss costs an hour's histogram rows and never an alert. 33rd since #4750: the Notification Channel Failing self-alert's catch, whose counts come from the webhook service's memory rather than the store. 34th since #4732: the Collection Falling Behind - self-alert's wrapper catch, whose counts come from the worker's in-memory gate statistics. */ - Assert.Equal(34, totalExempt); + self-alert's wrapper catch, whose counts come from the worker's in-memory gate statistics. 35th since + #5097: the store statement history snapshot's catch, a telemetry write sweep whose loss costs an hour's + capture and never an alert. 36th since #5450: the Collection Gap At Start self-alert's catch, handed + its evidence as a parameter. */ + Assert.Equal(36, totalExempt); /* Every exemption in the table is actually used. An exemption for a message that no longer exists is a hole this pin would otherwise keep open indefinitely — the shape that lets a real new catch diff --git a/Darling/Darling.Tests/AlertStatementCutOrderTests.cs b/Darling/Darling.Tests/AlertStatementCutOrderTests.cs new file mode 100644 index 0000000000..96c2c47e0a --- /dev/null +++ b/Darling/Darling.Tests/AlertStatementCutOrderTests.cs @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5320: an alert's statement text is judged WHOLE and then cut, never cut and then judged. The shared alert +/// builders (, , both apps) cut a statement to a +/// one-line preview for the alert body. A judge that sees the cut preview misses a value whose naming text (here a +/// CREATE LOGIN ... PASSWORD tail) lies past the cut, so every statement here carries a value in its first +/// part and its naming text well past 300 characters. Each pin fails when the cut comes first. +/// +public sealed class AlertStatementCutOrderTests +{ + private const string Value = "Hunter2Secret5320"; + + /// A value in the first characters, 600 characters of column list, then the text that names it. + private static readonly string Statement = + $"SELECT '{Value}' AS v" + string.Concat(Enumerable.Range(0, 60).Select(i => $", c{i:D4} AS a{i:D4}")) + + " INTO #t; CREATE LOGIN leaker WITH PASSWORD = 'x';"; + + private static readonly List NoExclusions = new(); + + private static string Flat(AlertContext? context) => + AlertContextBuilders.ContextToDetailText(context) ?? ""; + + private static void AssertWithheld(string text) + { + Assert.DoesNotContain(Value, text, StringComparison.Ordinal); + Assert.Contains(SensitiveStatements.PlaceholderText, text, StringComparison.Ordinal); + } + + [Fact] + public void TheFixtureStatement_IsNamedWhole_AndItsFirstThreeHundredCharactersAreNot() + { + Assert.True(Statement.IndexOf("CREATE LOGIN", StringComparison.Ordinal) > 600); + Assert.True(SensitiveStatements.Names(Statement)); + Assert.False(SensitiveStatements.Names(Statement[..300])); + Assert.Contains(Value, Statement[..300], StringComparison.Ordinal); + } + + [Fact] + public void TruncateStatement_JudgesTheWholeStatement_BeforeTheCut() + { + Assert.Equal(SensitiveStatements.PlaceholderText, AlertContextBuilders.TruncateStatement(Statement)); + Assert.Equal(SensitiveStatements.PlaceholderText, AlertContextBuilders.TruncateStatement(Statement, 80)); + } + + [Fact] + public void TruncateStatement_CutsAClearStatementTheWayTruncateTextDoes() + { + var clear = "SELECT a,\r\n b FROM t " + new string('x', 400); + + Assert.Equal(AlertContextBuilders.TruncateText(clear), AlertContextBuilders.TruncateStatement(clear)); + Assert.Equal(AlertContextBuilders.TruncateText(clear, 40), AlertContextBuilders.TruncateStatement(clear, 40)); + Assert.Equal("", AlertContextBuilders.TruncateStatement("")); + } + + [Fact] + public void BlockingContext_ShowsTheMarker_ForABlockedAndABlockingStatementWhoseNamingTextIsPastTheCut() + { + var rows = new List + { + new() + { + DatabaseName = "AppDb", WaitTimeMs = 9000, ContentiousObject = "AppDb.dbo.Ledger", + BlockedSqlText = Statement, BlockingSqlText = Statement, + }, + }; + + var context = AlertContextBuilders.BuildBlockingContext("SQL2022", rows, NoExclusions); + + AssertWithheld(Flat(context)); + } + + [Fact] + public void BlockingIncident_CarriesTheMarkerInItsDetailFields() + { + var rows = new List + { + new() + { + DatabaseName = "AppDb", WaitTimeMs = 9000, ContentiousObject = "AppDb.dbo.Ledger", + BlockedSqlText = Statement, BlockingSqlText = Statement, + }, + }; + + var incident = Assert.Single(AlertContextBuilders.BlockingIncidents("SQL2022", rows, NoExclusions)); + var fields = Assert.IsAssignableFrom>(incident.DetailFields); + var query = fields.Where(f => f.Label.EndsWith("Query", StringComparison.Ordinal)).ToList(); + + Assert.Equal(2, query.Count); + Assert.All(query, f => Assert.Equal(SensitiveStatements.PlaceholderText, f.Value)); + } + + [Fact] + public void BlockingIncident_KeepsItsIdentityOnTheRawText_SoAWithheldStatementDoesNotChangeTheKey() + { + /* No contentious object: identity falls back to database + the literal-stripped statement pair. */ + var rows = new List + { + new() { DatabaseName = "AppDb", WaitTimeMs = 9000, BlockedSqlText = Statement, BlockingSqlText = Statement + " " }, + new() { DatabaseName = "AppDb", WaitTimeMs = 8000, BlockedSqlText = Statement, BlockingSqlText = Statement + " " }, + }; + + var incidents = AlertContextBuilders.BlockingIncidents("SQL2022", rows, NoExclusions); + + Assert.Single(incidents); + } + + private const string StandaloneVictimGraph = "" + + "x" + + ""; + + private static string FingerprintedGraph(string inputbuf) => + "" + + $"{System.Security.SecurityElement.Escape(inputbuf)}" + + "SELECT 2" + + "" + + ""; + + [Fact] + public void DeadlockContext_ShowsTheMarker_ForAVictimStatementWithNoParseableObjects() + { + var rows = new List + { + new() { VictimProcessId = "process1", VictimSqlText = Statement, DeadlockGraphXml = StandaloneVictimGraph }, + }; + + var context = AlertContextBuilders.BuildDeadlockContext("SQL2022", rows, NoExclusions); + + AssertWithheld(Flat(context)); + } + + [Fact] + public void DeadlockContext_ShowsTheMarker_ForTheFingerprintedVictimAndEachPartyStatement() + { + var rows = new List + { + new() { VictimProcessId = "process1", VictimSqlText = Statement, DeadlockGraphXml = FingerprintedGraph(Statement) }, + }; + + var context = AlertContextBuilders.BuildDeadlockContext("SQL2022", rows, NoExclusions); + var text = Flat(context); + + AssertWithheld(text); + Assert.Contains("SELECT 2", text, StringComparison.Ordinal); + } + + [Fact] + public void LongRunningQueryContext_ShowsTheMarker_ForAStatementWhoseNamingTextIsPastTheCut() + { + var queries = new List + { + new() { SessionId = 61, DatabaseName = "AppDb", QueryText = Statement, ElapsedSeconds = 900 }, + }; + + var context = AlertContextBuilders.BuildLongRunningQueryContext("SQL2022", queries); + + AssertWithheld(Flat(context)); + } + + /// + /// The toast preview of the long-running-query alert is cut to 80 characters inside the engine's sweep, which + /// no pure seam reaches; this holds it to the judge-then-cut helper by its source. + /// + [Fact] + public void TheEnginesLongRunningQueryPreview_UsesTheJudgeThenCutHelper() + { + var source = RepoFile.ReadRepoFile("PerformanceMonitor.Alerting", "AlertEngine.cs"); + + Assert.Contains("AlertContextBuilders.TruncateStatement(worst.QueryText, 80)", source, StringComparison.Ordinal); + Assert.DoesNotContain("AlertContextBuilders.TruncateText(", source, StringComparison.Ordinal); + } + + /* The two PostgreSQL alert texts cut a statement the store already holds judged: a deadlock victim the log + parser took out of a report it had redacted whole, and a blocking root query PgBlockingCollector judges on the + target (PgSensitiveStatementFilterTests). These two pins read the chain through the parser. */ + [Fact] + public void PgDeadlockAlert_NeverCarriesAValueOfAVictimWhoseNamingTextIsPastTheCut() + { + var at = new DateTime(2026, 10, 6, 12, 0, 0, 100); + var prefix = at.ToString("yyyy-MM-dd HH:mm:ss.fff", System.Globalization.CultureInfo.InvariantCulture) + " UTC [3301] "; + var report = prefix + "ERROR: deadlock detected\n" + + prefix + "DETAIL: Process 3301 waits for ShareLock on transaction 5501; blocked by process 3302.\n" + + "\tProcess 3302 waits for ShareLock on transaction 5500; blocked by process 3301.\n" + + $"\tProcess 3301: SELECT '{Value}' AS v" + string.Concat(Enumerable.Range(0, 60).Select(i => $", c{i:D4}")) + + " INTO t; ALTER ROLE leaker PASSWORD 'x'\n" + + "\tProcess 3302: SELECT 2\n" + + prefix + "HINT: See server log for query details.\n"; + + var parsed = Assert.Single(PgDeadlockLogParser.Extract(report)); + var incident = DarlingWorker.BuildPgDeadlockIncident(new DarlingPgDeadlockReader.PgDeadlockRow( + parsed.OccurredAtUtc, parsed.VictimPid, parsed.ParticipantCount, parsed.DeadlockHash, + parsed.LockModes, parsed.Resources, parsed.VictimStatement, 1)); + + Assert.DoesNotContain(Value, string.Join("|", incident.InvolvedObjects), StringComparison.Ordinal); + Assert.DoesNotContain(Value, parsed.GraphText, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/AlwaysOnXeReadOnlyIntentTests.cs b/Darling/Darling.Tests/AlwaysOnXeReadOnlyIntentTests.cs new file mode 100644 index 0000000000..af1e901050 --- /dev/null +++ b/Darling/Darling.Tests/AlwaysOnXeReadOnlyIntentTests.cs @@ -0,0 +1,423 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using Xunit; + +namespace Darling.Tests; + +/// +/// Which connection each step of the deadlock and blocked-process sessions' ensure uses on Azure SQL Database, for a +/// registration with read-only intent and for one without (#4961). A session cannot be created or dropped over a connection +/// to a read-only replica, and a definition made on the primary replicates to it, so a registration with the intent creates +/// over a connection without it. The start, the probes and the read stay on the registration's own connection, because run +/// state is per replica. A session that runs on the replica is stopped there before it is dropped on the primary, on the +/// switch back and when the server is removed. Each test drives the real ensure against a scripted primary and replica, which +/// see the connection string each open would use. +/// +public sealed class AlwaysOnXeReadOnlyIntentTests : IAsyncDisposable +{ + private const string Host = "intent.database.windows.net"; + private const string InstallIdValue = "0a1b2c3d"; + + /* Never opened: the runner reaches the store only to write collected rows, and these tests collect nothing. */ + private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused"); + + public ValueTask DisposeAsync() => _store.DisposeAsync(); + + private static string Own(AlwaysOnXeSessionKind kind) => + AlwaysOnXeSessions.OwnNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue, kind); + + private static string Shared(AlwaysOnXeSessionKind kind) => AlwaysOnXeSessions.SharedNameFor(kind); + + private sealed class AlreadyThere : Exception + { + public AlreadyThere() : base("already there") { } + } + + /// + /// One Azure SQL Database as its primary and its read-only replica see it. A definition is made on the primary and + /// replicates at once; a session runs on one side or the other, and is started and stopped on its own. The replica + /// refuses a create or a drop with error 3906, and the primary refuses a drop while the session still runs on the replica + /// (the documented order: stop it there, then drop it on the primary). + /// + private sealed class Pair + { + public HashSet Definitions { get; } = new(StringComparer.Ordinal); + public HashSet WrongEvent { get; } = new(StringComparer.Ordinal); + public HashSet RunningOnPrimary { get; } = new(StringComparer.Ordinal); + public HashSet RunningOnReplica { get; } = new(StringComparer.Ordinal); + + /* Every open, and every call over a connection: its intent, then what it was. */ + public List Opens { get; } = new(); + public List<(ApplicationIntent Intent, string What)> Log { get; } = new(); + + /* A database that is read-only itself, reached without the intent: the primary refuses creates too. */ + public bool PrimaryIsReadOnly { get; set; } + + /// The (intent, verb) of each statement that names one session, in the order sent. + public List<(ApplicationIntent Intent, string Verb)> Statements(string name) => + Log.Where(e => e.What.StartsWith("DDL ", StringComparison.Ordinal) && e.What.Contains("[" + name + "]", StringComparison.Ordinal)) + .Select(e => (e.Intent, Verb: e.What["DDL ".Length..e.What.IndexOf(' ', "DDL ".Length)])) + .ToList(); + + public List ReadsOf(string prefix) => + Log.Where(e => e.What.StartsWith(prefix, StringComparison.Ordinal)).Select(e => e.Intent).ToList(); + } + + private sealed class Channel : IAlwaysOnXeDatabase + { + private readonly Pair _pair; + private readonly ApplicationIntent _intent; + + public Channel(Pair pair, ApplicationIntent intent) + { + _pair = pair; + _intent = intent; + } + + public Task ReadCatalogAsync(AlwaysOnXeSessionKind kind, string sessionName, CancellationToken cancellationToken) + { + _pair.Log.Add((_intent, "catalog " + sessionName)); + return Task.FromResult( + !_pair.Definitions.Contains(sessionName) ? AlwaysOnXeCatalog.Missing + : _pair.WrongEvent.Contains(sessionName) && kind == AlwaysOnXeSessionKind.Deadlock ? AlwaysOnXeCatalog.WrongEvent + : AlwaysOnXeCatalog.Present); + } + + public Task IsStartedAsync(string sessionName, CancellationToken cancellationToken) + { + _pair.Log.Add((_intent, "started " + sessionName)); + return Task.FromResult(Running.Contains(sessionName)); + } + + private HashSet Running => _intent == ApplicationIntent.ReadOnly ? _pair.RunningOnReplica : _pair.RunningOnPrimary; + + public Task ExecuteAsync(string statement, CancellationToken cancellationToken) + { + var text = statement.TrimStart(); + var name = text[(text.IndexOf('[') + 1)..text.IndexOf(']')]; + var creates = text.StartsWith("CREATE EVENT SESSION", StringComparison.Ordinal); + var starts = text.Contains("STATE = START", StringComparison.Ordinal); + var stops = text.Contains("STATE = STOP", StringComparison.Ordinal); + var drops = text.StartsWith("DROP EVENT SESSION", StringComparison.Ordinal); + var verb = creates && starts ? "CREATE+START" : creates ? "CREATE" : starts ? "START" : stops ? "STOP" : "DROP"; + _pair.Log.Add((_intent, "DDL " + verb + " [" + name + "]")); + + if ((creates || drops) && (_intent == ApplicationIntent.ReadOnly || _pair.PrimaryIsReadOnly)) + { + throw ReadOnlyRefusal(); + } + + if (creates) + { + if (_pair.Definitions.Contains(name)) + { + throw new AlreadyThere(); + } + + _pair.Definitions.Add(name); + if (starts) + { + Running.Add(name); + } + } + else if (drops) + { + if (_pair.RunningOnReplica.Contains(name)) + { + throw new InvalidOperationException("The session still runs on a read-only replica: stop it there first."); + } + + _pair.Definitions.Remove(name); + _pair.RunningOnPrimary.Remove(name); + } + else if (starts) + { + if (!_pair.Definitions.Contains(name)) + { + throw new InvalidOperationException("The session does not exist."); + } + + if (!Running.Add(name)) + { + throw new AlreadyThere(); + } + } + else if (stops && !Running.Remove(name)) + { + throw new InvalidOperationException("The session is not running here."); + } + + return Task.CompletedTask; + } + + public bool IsAlreadyPresent(Exception exception) => exception is AlreadyThere; + } + + private sealed class Rig + { + public required DarlingCollectorRunner Runner { get; init; } + public required ServerRuntime Runtime { get; init; } + public Pair Alpha { get; } = new(); + public DarlingSelfAlertTests.CapturingLogger Logger { get; } = new(); + + /* The connection string of every open, in order. */ + public List OpenedStrings { get; } = new(); + + public Task EnsureAsync() => DarlingXeSessions.EnsureAllAsync(Runtime, Runner, Logger, CancellationToken.None); + + /// + /// The server's removal: its long-query trace was never on (nothing to drop there), and no other registration + /// reads a session of its own, so every own fallback the choices hold is dropped. + /// + public Task RemoveAsync() => + DarlingRemovedServerSessions.DropAsync( + new RemovedLongQueryServer(Runtime.Config, Runtime, LongQueryTraceApplied: false), + Runner, remaining: null, traceOn: _ => false, + instanceGuard: _ => Task.FromResult(LongQueryTraceInstanceGuard.NoKeepers), Logger, CancellationToken.None); + + /// + /// Every line at Warning or above that this routine wrote, whatever it says. Nothing narrows it by a word, so a Warning a + /// test does not expect, such as a failed stop or a failed drop, is among them. + /// + public List Loud() => Logger.Entries + .Where(e => e.Level >= LogLevel.Warning) + .Select(e => e.Message) + .ToList(); + + /// + /// The one line a create that a read-only database refused logs at Warning for a capture: the registration, the database + /// and the message that session's kind is worded with. + /// + public string ReadOnlyLine(string database, AlwaysOnXeSessionKind kind) => + $"[{Runtime.Config.DisplayName}] [{database}] {AlwaysOnXeSessions.ReadOnlyDatabaseMessage(kind)}"; + } + + private Rig BuildRig(bool readOnlyIntent) + { + var config = new MonitoredServer { Name = "alwayson-intent", Host = Host, ReadOnlyIntent = readOnlyIntent }; + var runtime = new ServerRuntime + { + Config = config, + ConnectionString = $"Server=tcp:{Host},1433;Initial Catalog=master;Encrypt=True;ApplicationIntent={(readOnlyIntent ? "ReadOnly" : "ReadWrite")}", + Target = new CollectorTargetInfo { IsAzureSqlDb = true }, + StorageName = Host, + ServerId = 4961, + }; + var runner = new DarlingCollectorRunner( + _store, + new CollectorDeltaCalculator(), + databaseScope: (_, _) => new List(), + separatelyMonitoredDatabases: _ => new List(), + installId: () => InstallIdValue); + + var rig = new Rig { Runner = runner, Runtime = runtime }; + runner.AzureDatabaseListOverrideForTests = (_, _) => Task.FromResult(new List { "master", "alpha" }); + runner.AlwaysOnXeConnectionForTests = (_, database, connectionString, _) => + { + /* The removal reads the database name back from the choice's key, which keeps it in upper case. */ + Assert.Equal("alpha", database, ignoreCase: true); + Assert.Equal("alpha", new SqlConnectionStringBuilder(connectionString).InitialCatalog, ignoreCase: true); + var intent = new SqlConnectionStringBuilder(connectionString).ApplicationIntent; + rig.OpenedStrings.Add(connectionString); + rig.Alpha.Opens.Add(intent); + return Task.FromResult(new Channel(rig.Alpha, intent)); + }; + + return rig; + } + + private static readonly AlwaysOnXeSessionKind[] Kinds = { AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeSessionKind.BlockedProcess }; + + /* ── A registration with read-only intent ── */ + + [Fact] + public async Task ReadOnlyIntent_ASharedSessionThatIsMissing_IsCreatedOverAConnectionWithoutTheIntent_AndStartedOverTheOwnOne() + { + var rig = BuildRig(readOnlyIntent: true); + + await rig.EnsureAsync(); + + foreach (var kind in Kinds) + { + var shared = Shared(kind); + Assert.Equal( + new[] { (ApplicationIntent.ReadWrite, "CREATE"), (ApplicationIntent.ReadOnly, "START") }, + rig.Alpha.Statements(shared)); + Assert.Contains(shared, rig.Alpha.RunningOnReplica); + Assert.DoesNotContain(shared, rig.Alpha.RunningOnPrimary); + } + + /* The probes ran over the registration's own connection, and the read-write one was opened once, for the creates. */ + Assert.All(rig.Alpha.ReadsOf("catalog "), intent => Assert.Equal(ApplicationIntent.ReadOnly, intent)); + Assert.All(rig.Alpha.ReadsOf("started "), intent => Assert.Equal(ApplicationIntent.ReadOnly, intent)); + Assert.Equal(new[] { ApplicationIntent.ReadOnly, ApplicationIntent.ReadWrite }, rig.Alpha.Opens); + Assert.Empty(rig.Loud()); + } + + [Fact] + public async Task ReadOnlyIntent_AnOwnFallback_IsCreatedOverAConnectionWithoutTheIntent_AndStartedOverTheOwnOne() + { + var rig = BuildRig(readOnlyIntent: true); + var shared = Shared(AlwaysOnXeSessionKind.Deadlock); + rig.Alpha.Definitions.Add(shared); + rig.Alpha.WrongEvent.Add(shared); + rig.Alpha.RunningOnReplica.Add(shared); + + await rig.EnsureAsync(); + + var own = Own(AlwaysOnXeSessionKind.Deadlock); + Assert.Equal( + new[] { (ApplicationIntent.ReadWrite, "CREATE"), (ApplicationIntent.ReadOnly, "START") }, + rig.Alpha.Statements(own)); + Assert.Contains(own, rig.Alpha.RunningOnReplica); + Assert.Equal(own, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock)); + Assert.Empty(rig.Alpha.Statements(shared)); + } + + [Fact] + public async Task ReadOnlyIntent_SwitchingBack_StopsTheOwnSessionOverTheOwnConnection_ThenDropsItOverAConnectionWithoutTheIntent() + { + var rig = BuildRig(readOnlyIntent: true); + var shared = Shared(AlwaysOnXeSessionKind.Deadlock); + var own = Own(AlwaysOnXeSessionKind.Deadlock); + rig.Alpha.Definitions.Add(shared); + rig.Alpha.WrongEvent.Add(shared); + rig.Alpha.RunningOnReplica.Add(shared); + await rig.EnsureAsync(); + Assert.Contains(own, rig.Alpha.RunningOnReplica); + rig.Alpha.Log.Clear(); + + rig.Alpha.WrongEvent.Clear(); + await rig.EnsureAsync(); + + Assert.Equal( + new[] { (ApplicationIntent.ReadOnly, "STOP"), (ApplicationIntent.ReadWrite, "DROP") }, + rig.Alpha.Statements(own)); + Assert.DoesNotContain(own, rig.Alpha.Definitions); + Assert.Equal(shared, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock)); + Assert.DoesNotContain(rig.Alpha.Statements(shared), s => s.Verb is "CREATE" or "CREATE+START" or "DROP"); + } + + [Fact] + public async Task ReadOnlyIntent_ARemovedServer_StopsItsOwnSessionOverTheOwnConnection_ThenDropsItOverAConnectionWithoutTheIntent() + { + var rig = BuildRig(readOnlyIntent: true); + var own = Own(AlwaysOnXeSessionKind.Deadlock); + rig.Alpha.Definitions.Add(own); + rig.Alpha.RunningOnReplica.Add(own); + rig.Runner.AlwaysOnChoices.Set(DarlingAlwaysOnXeSessions.ServerKey(rig.Runtime), "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own); + + await rig.RemoveAsync(); + + Assert.Equal( + new[] { (ApplicationIntent.ReadOnly, "STOP"), (ApplicationIntent.ReadWrite, "DROP") }, + rig.Alpha.Statements(own)); + Assert.DoesNotContain(own, rig.Alpha.Definitions); + Assert.Empty(rig.Loud()); + } + + [Fact] + public async Task ReadOnlyIntent_ARemovedServer_WhoseOwnSessionDoesNotRunOnTheReplica_DropsItWithoutAStop() + { + var rig = BuildRig(readOnlyIntent: true); + var own = Own(AlwaysOnXeSessionKind.Deadlock); + rig.Alpha.Definitions.Add(own); + rig.Runner.AlwaysOnChoices.Set(DarlingAlwaysOnXeSessions.ServerKey(rig.Runtime), "alpha", AlwaysOnXeSessionKind.Deadlock, AlwaysOnXeChoice.Own); + + await rig.RemoveAsync(); + + Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "DROP") }, rig.Alpha.Statements(own)); + Assert.DoesNotContain(own, rig.Alpha.Definitions); + Assert.Empty(rig.Loud()); + } + + [Fact] + public async Task ReadOnlyIntent_AHealthyDatabase_OpensNoConnectionWithoutTheIntent() + { + var rig = BuildRig(readOnlyIntent: true); + foreach (var kind in Kinds) + { + rig.Alpha.Definitions.Add(Shared(kind)); + rig.Alpha.RunningOnReplica.Add(Shared(kind)); + } + + await rig.EnsureAsync(); + + Assert.Equal(new[] { ApplicationIntent.ReadOnly }, rig.Alpha.Opens); + Assert.DoesNotContain(rig.Alpha.Log, e => e.What.StartsWith("DDL ", StringComparison.Ordinal)); + } + + /* ── A registration without it, and on-premises, are unchanged ── */ + + [Fact] + public async Task NoIntent_EverythingGoesOverTheOwnConnection_AsItAlwaysHas() + { + var rig = BuildRig(readOnlyIntent: false); + var shared = Shared(AlwaysOnXeSessionKind.Deadlock); + var own = Own(AlwaysOnXeSessionKind.Deadlock); + + /* The shared create is one batch, with its start. */ + await rig.EnsureAsync(); + Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "CREATE+START") }, rig.Alpha.Statements(shared)); + + /* A fallback is the same, and its drop on the switch back is the one statement, with no stop first. */ + rig.Alpha.WrongEvent.Add(shared); + rig.Alpha.Log.Clear(); + await rig.EnsureAsync(); + Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "CREATE+START") }, rig.Alpha.Statements(own)); + rig.Alpha.WrongEvent.Clear(); + rig.Alpha.Log.Clear(); + await rig.EnsureAsync(); + Assert.Equal(new[] { (ApplicationIntent.ReadWrite, "DROP") }, rig.Alpha.Statements(own)); + + Assert.All(rig.Alpha.Opens, intent => Assert.Equal(ApplicationIntent.ReadWrite, intent)); + Assert.Equal(3, rig.Alpha.Opens.Count); + } + + /* ── A read-only database reached without the intent (an Azure geo-secondary) ── */ + + [Fact] + public async Task AReadOnlyDatabaseWithoutTheIntent_LogsTheReadOnlyExplanationOnce_NotTheCapsSentence_AndTheRetriesStayQuiet() + { + var rig = BuildRig(readOnlyIntent: false); + rig.Alpha.PrimaryIsReadOnly = true; + + await rig.EnsureAsync(); + + /* One line a capture at Warning or above, whatever it says, and each is the read-only message: why, and what to change. */ + var expected = Kinds.Select(kind => rig.ReadOnlyLine("alpha", kind)).OrderBy(line => line, StringComparer.Ordinal).ToList(); + Assert.Equal(expected, rig.Loud().OrderBy(line => line, StringComparer.Ordinal)); + Assert.All(rig.Loud(), line => + { + Assert.Contains("read-only", line, StringComparison.Ordinal); + Assert.Contains("3906", line, StringComparison.Ordinal); + Assert.Contains("Register the primary database instead", line, StringComparison.Ordinal); + Assert.DoesNotContain(AlwaysOnXeSessions.AzureCapsSentence, line, StringComparison.Ordinal); + }); + + /* The pass an hour later asks again, and says nothing new at Warning. */ + await rig.EnsureAsync(); + await rig.EnsureAsync(); + Assert.Equal(expected, rig.Loud().OrderBy(line => line, StringComparer.Ordinal)); + } + + private static SqlException ReadOnlyRefusal() => + LongQueryTraceReadOnlyIntentTests.SqlExceptionFactory.Create( + LongQueryTraceDatabases.ReadOnlyDatabaseErrorNumber, 16, "Failed to update database because the database is read-only."); +} diff --git a/Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs b/Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs new file mode 100644 index 0000000000..f1dcd96f7c --- /dev/null +++ b/Darling/Darling.Tests/AlwaysOnXeSessionsTests.cs @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// The deadlock and blocked-process sessions on Azure SQL Database (#4961), as the service ensures them: a stopped shared +/// session gets a start by name, one that stays unusable sends the install to its own session, the install switches back +/// once the shared one reads back usable, no path drops a shared-name session, and the read takes the name the ensure chose. +/// The service ensures at connect and then once an hour, so a session dropped from outside comes back within the hour. Each +/// test drives the real ensure against a scripted database, so no server or store is needed. +/// +public sealed class AlwaysOnXeSessionsTests : IAsyncDisposable +{ + private const string Host = "alwayson.database.windows.net"; + private const string InstallIdValue = "0a1b2c3d"; + + /* Never opened: the runner reaches the store only to write collected rows, and these tests collect nothing. */ + private readonly NpgsqlDataSource _store = NpgsqlDataSource.Create("Host=127.0.0.1;Port=1;Database=unused;Username=unused"); + + public ValueTask DisposeAsync() => _store.DisposeAsync(); + + private static string Own(AlwaysOnXeSessionKind kind) => + AlwaysOnXeSessions.OwnNameFor(LongQueryCompletionsCollector.DarlingProduct, InstallIdValue, kind); + + [Theory] + [InlineData(AlwaysOnXeSessionKind.Deadlock)] + [InlineData(AlwaysOnXeSessionKind.BlockedProcess)] + public void TheSharedSessionStartsWithTheDatabase_AndThisInstallsOwnFallbackSessionDoesNot(AlwaysOnXeSessionKind kind) + { + var shared = AlwaysOnXeSessions.BuildAzureCreateSql(kind, AlwaysOnXeSessions.SharedNameFor(kind)); + var own = AlwaysOnXeSessions.BuildAzureCreateSql(kind, Own(kind)); + + Assert.Contains("STARTUP_STATE = ON", shared, StringComparison.Ordinal); + Assert.DoesNotContain("STARTUP_STATE = OFF", shared, StringComparison.Ordinal); + Assert.Contains("STARTUP_STATE = OFF", own, StringComparison.Ordinal); + Assert.DoesNotContain("STARTUP_STATE = ON", own, StringComparison.Ordinal); + } + + private sealed class AlreadyThere : Exception + { + public AlreadyThere() : base("already there") { } + } + + /// One Azure SQL Database as the ensure sees it: a catalog, a started-sessions DMV, and the statements sent. + private sealed class ScriptedDatabase : IAlwaysOnXeDatabase + { + public Dictionary Started { get; } = new(StringComparer.Ordinal); + public HashSet WrongEvent { get; } = new(StringComparer.Ordinal); + public List Statements { get; } = new(); + public Exception? CreateFailure { get; set; } + + public Task ReadCatalogAsync(AlwaysOnXeSessionKind kind, string sessionName, CancellationToken cancellationToken) => + Task.FromResult( + !Started.ContainsKey(sessionName) ? AlwaysOnXeCatalog.Missing + : WrongEvent.Contains(sessionName) && kind == AlwaysOnXeSessionKind.Deadlock ? AlwaysOnXeCatalog.WrongEvent + : AlwaysOnXeCatalog.Present); + + public Task IsStartedAsync(string sessionName, CancellationToken cancellationToken) => + Task.FromResult(Started.TryGetValue(sessionName, out var started) && started); + + public Task ExecuteAsync(string statement, CancellationToken cancellationToken) + { + Statements.Add(statement); + var name = statement[(statement.IndexOf('[') + 1)..statement.IndexOf(']')]; + + if (statement.TrimStart().StartsWith("CREATE EVENT SESSION", StringComparison.Ordinal)) + { + if (CreateFailure is not null) + { + throw CreateFailure; + } + + if (Started.ContainsKey(name)) + { + throw new AlreadyThere(); + } + + Started[name] = true; + } + else if (statement.StartsWith("ALTER EVENT SESSION", StringComparison.Ordinal)) + { + if (Started.TryGetValue(name, out var already) && already) + { + throw new AlreadyThere(); + } + + Started[name] = true; + } + else if (statement.StartsWith("DROP EVENT SESSION", StringComparison.Ordinal)) + { + Started.Remove(name); + } + + return Task.CompletedTask; + } + + public bool IsAlreadyPresent(Exception exception) => exception is AlreadyThere; + + public int Created(string name) => + Statements.Count(s => s.TrimStart().StartsWith("CREATE EVENT SESSION [" + name + "]", StringComparison.Ordinal)); + + public bool Sent(string prefix, string name) => + Statements.Any(s => s.TrimStart().StartsWith(prefix + " [" + name + "]", StringComparison.Ordinal)); + } + + private sealed class Rig + { + public required DarlingCollectorRunner Runner { get; init; } + public required ServerRuntime Runtime { get; init; } + public required DarlingWorker.ServerLoopState State { get; init; } + public ScriptedDatabase Alpha { get; } = new(); + public ScriptedDatabase Beta { get; } = new(); + public int ServerScopedEnsures { get; set; } + } + + private Rig BuildRig(bool azureSqlDatabase = true) + { + var config = new MonitoredServer { Name = "alwayson", Host = Host }; + var runtime = new ServerRuntime + { + Config = config, + ConnectionString = $"Server=tcp:{Host},1433;Initial Catalog=master;Encrypt=True", + Target = new CollectorTargetInfo { IsAzureSqlDb = azureSqlDatabase }, + StorageName = Host, + ServerId = 4242, + }; + var runner = new DarlingCollectorRunner( + _store, + new CollectorDeltaCalculator(), + databaseScope: (_, _) => new List(), + separatelyMonitoredDatabases: _ => new List(), + installId: () => InstallIdValue); + + var rig = new Rig + { + Runner = runner, + Runtime = runtime, + State = new DarlingWorker.ServerLoopState { Config = config, Runtime = runtime }, + }; + + runner.AzureDatabaseListOverrideForTests = (_, _) => Task.FromResult(new List { "master", "alpha", "beta" }); + runner.AlwaysOnXeDatabaseForTests = (_, database, _) => + Task.FromResult(string.Equals(database, "alpha", StringComparison.OrdinalIgnoreCase) ? rig.Alpha : rig.Beta); + runner.XeEnsureOverrideForTests = (_, _) => + { + rig.ServerScopedEnsures++; + return Task.CompletedTask; + }; + + return rig; + } + + private static Task EnsureAsync(Rig rig) => + DarlingXeSessions.EnsureAllAsync(rig.Runtime, rig.Runner, NullLogger.Instance, CancellationToken.None); + + /* Test 16: a stopped shared session gets a START by name and no fallback. */ + [Fact] + public async Task AStoppedSharedSession_GetsAStartByName_AndNoFallback() + { + var rig = BuildRig(); + rig.Runner.XeEnsureOverrideForTests = null; + var deadlock = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock); + var blocked = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.BlockedProcess); + rig.Alpha.Started[deadlock] = false; + rig.Alpha.Started[blocked] = false; + + await EnsureAsync(rig); + + Assert.True(rig.Alpha.Started[deadlock]); + Assert.True(rig.Alpha.Started[blocked]); + Assert.Equal(0, rig.Alpha.Created(Own(AlwaysOnXeSessionKind.Deadlock))); + Assert.Equal(deadlock, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock)); + Assert.DoesNotContain(rig.Alpha.Statements, s => s.StartsWith("DROP", StringComparison.Ordinal)); + } + + /* Tests 15 and 17: a shared deadlock session without the deadlock event is never dropped, the install reads its own. */ + [Fact] + public async Task ASharedDeadlockSessionWithoutTheDeadlockEvent_IsNeverDropped_AndTheReadTakesTheOwnName() + { + var rig = BuildRig(); + rig.Runner.XeEnsureOverrideForTests = null; + var shared = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock); + rig.Alpha.Started[shared] = true; + rig.Alpha.WrongEvent.Add(shared); + + await EnsureAsync(rig); + + Assert.False(rig.Alpha.Sent("DROP EVENT SESSION", shared)); + Assert.Equal(Own(AlwaysOnXeSessionKind.Deadlock), rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock)); + Assert.Equal(shared, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "beta", AlwaysOnXeSessionKind.Deadlock)); + Assert.Contains("STARTUP_STATE = OFF", rig.Alpha.Statements.Single(s => s.Contains("CREATE EVENT SESSION [" + Own(AlwaysOnXeSessionKind.Deadlock) + "]", StringComparison.Ordinal)), StringComparison.Ordinal); + } + + /* Test 18: a shared session that reads back usable makes the install drop its fallback and switch back. */ + [Fact] + public async Task TheInstallOnItsOwnSession_DropsItAndSwitchesBack_WhenTheSharedSessionReadsBackUsable() + { + var rig = BuildRig(); + rig.Runner.XeEnsureOverrideForTests = null; + var shared = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock); + rig.Alpha.Started[shared] = true; + rig.Alpha.WrongEvent.Add(shared); + await EnsureAsync(rig); + Assert.True(rig.Alpha.Started.ContainsKey(Own(AlwaysOnXeSessionKind.Deadlock))); + + rig.Alpha.WrongEvent.Clear(); + await EnsureAsync(rig); + + Assert.False(rig.Alpha.Started.ContainsKey(Own(AlwaysOnXeSessionKind.Deadlock))); + Assert.True(rig.Alpha.Sent("DROP EVENT SESSION", Own(AlwaysOnXeSessionKind.Deadlock))); + Assert.False(rig.Alpha.Sent("DROP EVENT SESSION", shared)); + Assert.Equal(shared, rig.Runner.AlwaysOnReadSessionName(rig.Runtime, "alpha", AlwaysOnXeSessionKind.Deadlock)); + } + + /* Test 19, Azure: sessions dropped from outside come back within the hour, and not before it. */ + [Fact] + public async Task AzureSessionsDroppedFromOutside_ComeBackWithinTheHour() + { + var rig = BuildRig(); + rig.Runner.XeEnsureOverrideForTests = null; + var start = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var deadlock = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.Deadlock); + var blocked = AlwaysOnXeSessions.SharedNameFor(AlwaysOnXeSessionKind.BlockedProcess); + + await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start, NullLogger.Instance, CancellationToken.None); + Assert.True(rig.Alpha.Started[deadlock]); + Assert.True(rig.Beta.Started[blocked]); + + rig.Alpha.Started.Clear(); + rig.Beta.Started.Clear(); + await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(30), NullLogger.Instance, CancellationToken.None); + Assert.Empty(rig.Alpha.Started); + + await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(61), NullLogger.Instance, CancellationToken.None); + Assert.True(rig.Alpha.Started[deadlock]); + Assert.True(rig.Alpha.Started[blocked]); + Assert.True(rig.Beta.Started[deadlock]); + Assert.True(rig.Beta.Started[blocked]); + } + + /* Test 19, server scope: the ensure runs once an hour as well as at connect. */ + [Fact] + public async Task TheServerScopedEnsure_RunsAgainOnceTheHourIsUp() + { + var rig = BuildRig(azureSqlDatabase: false); + var start = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start, NullLogger.Instance, CancellationToken.None); + await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(59), NullLogger.Instance, CancellationToken.None); + Assert.Equal(1, rig.ServerScopedEnsures); + + await DarlingWorker.EnsureAlwaysOnXeSessionsAsync(rig.State, rig.Runner, start.AddMinutes(60), NullLogger.Instance, CancellationToken.None); + Assert.Equal(2, rig.ServerScopedEnsures); + Assert.Equal(start.AddMinutes(60), rig.State.XeSessionsEnsuredAtUtc); + } + + /* Test 27: an Azure create that fails carries the caps sentence in the line the service logs. */ + [Fact] + public async Task AFailedAzureCreate_LogsTheCapsSentence() + { + var rig = BuildRig(); + rig.Runner.XeEnsureOverrideForTests = null; + rig.Alpha.CreateFailure = new InvalidOperationException("The create was refused."); + var logger = new CapturingLogger(); + + await DarlingXeSessions.EnsureAllAsync(rig.Runtime, rig.Runner, logger, CancellationToken.None); + + var line = Assert.Single(logger.Lines, l => l.Contains("alpha", StringComparison.Ordinal) && l.Contains("deadlock", StringComparison.Ordinal)); + Assert.Contains("The create was refused.", line, StringComparison.Ordinal); + Assert.Contains(AlwaysOnXeSessions.AzureCapsSentence, line, StringComparison.Ordinal); + } + + /* Test 15, pinned at the source: the Azure ensure in this service holds no drop of a shared name. */ + [Fact] + public void TheAzureEnsure_HoldsNoDropOfASharedName() + { + var source = ReadRepoFileLf("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs"); + + Assert.DoesNotContain("DROP EVENT SESSION [{DeadlocksCollector.XeSessionName}]", source, StringComparison.Ordinal); + Assert.DoesNotContain("DROP EVENT SESSION [{sessionName}] ON DATABASE;\", connection))\n {\n dropCmd.CommandTimeout = 60;", source, StringComparison.Ordinal); + } + + /* Test 17: the read builders name the session the ensure chose. */ + [Fact] + public void TheReadBuildersNameTheSessionTheEnsureChose() + { + var own = Own(AlwaysOnXeSessionKind.Deadlock); + var ownBlocked = Own(AlwaysOnXeSessionKind.BlockedProcess); + + Assert.Contains($"N'{own}'", DeadlocksCollector.Instance.BuildQuery(AzureContext(own)).Text, StringComparison.Ordinal); + Assert.DoesNotContain("N'PerformanceMonitor_Deadlock'", DeadlocksCollector.Instance.BuildQuery(AzureContext(own)).Text, StringComparison.Ordinal); + Assert.Contains("N'PerformanceMonitor_Deadlock'", DeadlocksCollector.Instance.BuildQuery(AzureContext(null)).Text, StringComparison.Ordinal); + Assert.Contains($"N'{ownBlocked}'", BlockedProcessReportCollector.Instance.BuildQuery(AzureContext(ownBlocked)).Text, StringComparison.Ordinal); + Assert.DoesNotContain("N'PerformanceMonitor_BlockedProcess'", BlockedProcessReportCollector.Instance.BuildQuery(AzureContext(ownBlocked)).Text, StringComparison.Ordinal); + } + + private static CollectorContext AzureContext(string? sessionName) => new() + { + ServerId = 1, + ServerName = "alwayson", + CollectionTime = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Deltas = new CollectorDeltaCalculator(), + Target = new CollectorTargetInfo { IsAzureSqlDb = true }, + CurrentDatabaseName = "alpha", + AlwaysOnSessionName = sessionName, + }; + + private sealed class CapturingLogger : Microsoft.Extensions.Logging.ILogger + { + public List Lines { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, Exception? exception, Func formatter) => + Lines.Add(formatter(state, exception)); + } +} diff --git a/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs b/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs index 5842e2fd43..cb611ccbf6 100644 --- a/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs +++ b/Darling/Darling.Tests/AnalysisFactsReadRunsDetectorParityTests.cs @@ -35,6 +35,7 @@ namespace Darling.Tests; /// service over the batch-request spike fixture; Darling's gated PgTargetAnomalyTests e2e asserts /// the planted TPS spike through the real get_analysis_facts. This file is the shape both rest on. /// +[Trait("Reads", "Lite")] public sealed class AnalysisFactsReadRunsDetectorParityTests { private const string ReadEntry = "public async Task<(List Facts, WindowCoverage? Coverage, CollectionCaveatState Caveats)> CollectAndScoreFactsAsync("; diff --git a/Darling/Darling.Tests/AnalysisFindingsTabTests.cs b/Darling/Darling.Tests/AnalysisFindingsTabTests.cs new file mode 100644 index 0000000000..13a858e110 --- /dev/null +++ b/Darling/Darling.Tests/AnalysisFindingsTabTests.cs @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// The web Recommendations tab (#4843), the twin of the desktop viewer's analysis findings view. The source pins +/// name the read and its params and where the tab sits in the registry; the behaviour tests run the shipped +/// analysis-findings.js under Node (analysis-findings-harness.mjs), skipped when Node is not installed. +/// +public sealed class AnalysisFindingsTabTests +{ + private static string[] Tab() => new[] { "Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "analysis-findings.js" }; + + [Fact] + public void TheTab_ReadsGetAnalysisFindings_WithTheServerWindowAndFullText() + { + var src = ReadRepoFile(Tab()); + Assert.Contains("readToolWithinKeptHistory(\"get_analysis_findings\", { server, hours: ctx.hours, limit: 50, full_text: true }, ctx && ctx.signal)", src, StringComparison.Ordinal); + Assert.DoesNotContain("innerHTML", src, StringComparison.Ordinal); + Assert.DoesNotContain("analyze_server", src, StringComparison.Ordinal); + } + + [Fact] + public void TheTab_IsRegisteredLast_InTheSqlServerRegistryOnly() + { + var tabs = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js").ReplaceLineEndings("\n"); + Assert.Contains("import { analysisFindingsTab } from \"./analysis-findings.js\";", tabs, StringComparison.Ordinal); + Assert.Contains(" analysisFindingsTab,\n];\n\n/* ─", tabs, StringComparison.Ordinal); + Assert.Equal(1, tabs.Split("analysisFindingsTab,", StringSplitOptions.None).Length - 1); + } + + private static JsonElement Run(string scenario) + { + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "analysis-findings-harness.mjs")); + psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js")); + psi.ArgumentList.Add(scenario); + Process proc; + try { proc = Process.Start(psi)!; } + catch (Win32Exception) { Assert.Skip("Node is not installed, so the shipped page script cannot be run."); return default; } + using (proc) + { + var error = proc.StandardError.ReadToEndAsync(); + var output = proc.StandardOutput.ReadToEnd().Trim(); + if (!proc.WaitForExit(20000)) + { + proc.Kill(entireProcessTree: true); + Assert.Fail("the analysis findings harness did not finish in 20 s for scenario " + scenario); + } + Assert.True(proc.ExitCode == 0, "the analysis findings harness failed for " + scenario + ": " + error.Result); + using var doc = JsonDocument.Parse(output); + return doc.RootElement.Clone(); + } + } + + private static string[] Strings(JsonElement e) => e.EnumerateArray().Select(x => x.GetString()!).ToArray(); + + [Fact] + public void TheRead_GoesToTheApiWithTheServerHoursLimitAndFullText() + { + var r = Run("read"); + Assert.Equal("/api/read/get_analysis_findings", r.GetProperty("path").GetString()); + var p = r.GetProperty("params"); + Assert.Equal("srv-a", p.GetProperty("server").GetString()); + Assert.Equal("24", p.GetProperty("hours").GetString()); + Assert.Equal("50", p.GetProperty("limit").GetString()); + Assert.Equal("true", p.GetProperty("full_text").GetString()); + } + + [Fact] + public void Findings_GroupByIncident_MostSevereFirst_WithBadgesFromTheExistingBandClasses() + { + var r = Run("grouping"); + Assert.Equal(new[] { "Major · 2 findings · CRITICAL", "Config · CRITICAL", "Solo info · INFO" }, Strings(r.GetProperty("headers"))); + Assert.Equal(new[] { 2, 1, 1 }, r.GetProperty("cardsPerGroup").EnumerateArray().Select(x => x.GetInt32()).ToArray()); + // Info-only groups start collapsed, the rest open. + Assert.Equal(new[] { true, true, false }, r.GetProperty("open").EnumerateArray().Select(x => x.GetBoolean()).ToArray()); + var classes = Strings(r.GetProperty("badgeClasses")); + Assert.Contains("badge band-Critical", classes); + Assert.Contains("badge band-Warning", classes); + Assert.Contains("badge band-Unknown", classes); + } + + [Fact] + public void TheFixScript_IsText_InAPre_AndCopyPutsExactlyThatTextOnTheClipboard() + { + var r = Run("fix"); + const string fix = "ALTER DATABASE x SET AUTO_SHRINK OFF;"; + Assert.Equal(fix, Assert.Single(Strings(r.GetProperty("pre")))); + Assert.Equal(0, Assert.Single(r.GetProperty("preChildren").EnumerateArray()).GetInt32()); + Assert.Equal(fix, Assert.Single(Strings(r.GetProperty("clipboard")))); + Assert.Equal("Copied", r.GetProperty("label").GetString()); + Assert.Equal(1, r.GetProperty("buttons").GetInt32()); + } + + [Fact] + public void OnlyIncidentFindings_LinkToTheServersQueriesTab_NotConfigFixes() + { + var links = Strings(Run("link")); + Assert.Equal(3, links.Length); + Assert.All(links, l => Assert.Equal("#/server/srv-a/queries", l)); + } + + [Fact] + public void AForcePlanIncident_KeepsTheLink_AndAConfigFix_DoesNot() + { + var groups = Run("linkKinds").EnumerateArray().ToDictionary(g => g.GetProperty("head").GetString()!, g => g.GetProperty("links").GetInt32()); + Assert.Equal(1, groups.Single(g => g.Key.StartsWith("ForcePlan", StringComparison.Ordinal)).Value); + Assert.Equal(0, groups.Single(g => g.Key.StartsWith("ConfigFix", StringComparison.Ordinal)).Value); + } + + [Fact] + public void ANullSeverity_RendersTheLabelAlone_NotNaN() + { + var text = Run("nullSeverity").GetProperty("text").GetString()!; + Assert.DoesNotContain("NaN", text, StringComparison.Ordinal); + Assert.DoesNotContain("0.00", text, StringComparison.Ordinal); + Assert.Contains("INFO", text, StringComparison.Ordinal); + } + + [Fact] + public void TheReadCapNote_AndThePageNote_AreBothShown() + { + var text = Run("truncation").GetProperty("text").GetString()!; + Assert.Contains("READ-CAP-NOTE", text, StringComparison.Ordinal); + Assert.Contains("PAGE-NOTE", text, StringComparison.Ordinal); + } + + [Fact] + public void TheSecondaryReplicaNote_ShowsWithFindingsAndWithNone_AndIsAbsentWhenTheReadCarriesNone() + { + var r = Run("replicaNote"); + Assert.Contains("REPLICA-NOTE", r.GetProperty("withFindings").GetString(), StringComparison.Ordinal); + Assert.Contains("REPLICA-NOTE", r.GetProperty("noFindings").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain("REPLICA-NOTE", r.GetProperty("without").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain("REPLICA-NOTE", r.GetProperty("nullNote").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain("null", r.GetProperty("nullNote").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void TheTabsAbortSignal_ReachesTheRead() + { + Assert.True(Run("signal").GetProperty("passed").GetBoolean()); + } + + [Fact] + public void TheReadsConfigFixFlag_IsTheDesktopsRule_AndIsSharedWithTheViewer() + { + var tools = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpTools.cs"); + Assert.Contains("is_config_fix = FactRemediation.IsConfigFix(f.Remediation)", tools, StringComparison.Ordinal); + var vm = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "RecommendationsViewModel.cs"); + Assert.Contains("HasStructuredFixAction => FactRemediation.IsConfigFix(Item.Remediation)", vm, StringComparison.Ordinal); + var js = ReadRepoFile(Tab()); + Assert.Contains("!f.is_config_fix", js, StringComparison.Ordinal); + Assert.DoesNotContain("f.structured_remediation", js, StringComparison.Ordinal); + } + + [Fact] + public void OpenAndClosedGroups_SurviveARebuild_PerServer() + { + var r = Run("rebuild"); + Assert.Equal(new[] { false, true, true }, r.GetProperty("again").EnumerateArray().Select(x => x.GetBoolean()).ToArray()); + Assert.Equal(new[] { true, true, false }, r.GetProperty("other").EnumerateArray().Select(x => x.GetBoolean()).ToArray()); + } + + [Fact] + public void AnEmptyRead_ShowsTheServersSentence() + { + Assert.Contains("No findings in the requested time range.", Run("empty").GetProperty("text").GetString(), StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/AnalysisReadCancellationPinTests.cs b/Darling/Darling.Tests/AnalysisReadCancellationPinTests.cs index fc1cf3deaa..4751697b8f 100644 --- a/Darling/Darling.Tests/AnalysisReadCancellationPinTests.cs +++ b/Darling/Darling.Tests/AnalysisReadCancellationPinTests.cs @@ -33,6 +33,7 @@ namespace Darling.Tests; /// public method, not a private catch site called directly), with no rig and no store, on a port nothing /// listens on. /// +[Trait("Stage", "Guard")] public sealed class AnalysisReadCancellationPinTests { private static NpgsqlDataSource DeadStore() => diff --git a/Darling/Darling.Tests/AnalysisWindowEmptyRuleParityTests.cs b/Darling/Darling.Tests/AnalysisWindowEmptyRuleParityTests.cs index b83c299dcc..a4bc6b60ab 100644 --- a/Darling/Darling.Tests/AnalysisWindowEmptyRuleParityTests.cs +++ b/Darling/Darling.Tests/AnalysisWindowEmptyRuleParityTests.cs @@ -30,6 +30,7 @@ namespace Darling.Tests; /// across the SKUs, and still carries the #3524 prose, because that prose is now true of exactly the case it /// is emitted for. /// +[Trait("Reads", "Lite")] public sealed class AnalysisWindowEmptyRuleParityTests { private const string PassEntry = "public async Task> AnalyzeAsync(AnalysisContext context)"; diff --git a/Darling/Darling.Tests/AnomalyDetectorErrorGuardLiveTests.cs b/Darling/Darling.Tests/AnomalyDetectorErrorGuardLiveTests.cs index 9deaa4206a..9e19b645eb 100644 --- a/Darling/Darling.Tests/AnomalyDetectorErrorGuardLiveTests.cs +++ b/Darling/Darling.Tests/AnomalyDetectorErrorGuardLiveTests.cs @@ -223,7 +223,7 @@ private static void RedFirstProof_DocumentedNotAsserted() private static async Task RunBothWindowsAsync(IAnomalyDetector detector, int serverId, string serverName) { var now = DateTime.SpecifyKind( - new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)), + new DateTime(DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond), DateTimeKind.Unspecified); foreach (var window in new[] { TimeSpan.FromHours(4), TimeSpan.FromHours(24) }) @@ -257,7 +257,7 @@ private static async Task RunBothWindowsAsync(IAnomalyDetector detector, i private static async Task SeedSqlServerFamilyTablesAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) { var now = DateTime.SpecifyKind( - new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)), + new DateTime(DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond), DateTimeKind.Unspecified); // Inside every 24h window this class runs. @@ -353,7 +353,7 @@ private static async Task DeleteSqlServerRowsAsync(NpgsqlConnection connection, private static async Task SeedPgTargetFamilyTablesAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) { var now = DateTime.SpecifyKind( - new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)), + new DateTime(DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond), DateTimeKind.Unspecified); var t = now.AddHours(-1); var priorMinute = t.AddMinutes(-1); diff --git a/Darling/Darling.Tests/AnomalyNullWindowLiveTests.cs b/Darling/Darling.Tests/AnomalyNullWindowLiveTests.cs index 5543b75e9a..e67d5bbf81 100644 --- a/Darling/Darling.Tests/AnomalyNullWindowLiveTests.cs +++ b/Darling/Darling.Tests/AnomalyNullWindowLiveTests.cs @@ -98,7 +98,7 @@ as residue. Move onto a PRIVATE scratch database instead — SharedBaselineCache /* now: truncated to whole seconds (the lane rule) — this becomes the "as_of" anchor for every pass; history runs back HistoryDays from it. */ var now = DateTime.SpecifyKind( - new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)), + new DateTime(DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond * TimeSpan.TicksPerSecond), DateTimeKind.Unspecified); var historyStart = now.AddDays(-HistoryDays); diff --git a/Darling/Darling.Tests/AnomalyTileWindowEndTests.cs b/Darling/Darling.Tests/AnomalyTileWindowEndTests.cs index 0fe7454d5c..dc3ca673fb 100644 --- a/Darling/Darling.Tests/AnomalyTileWindowEndTests.cs +++ b/Darling/Darling.Tests/AnomalyTileWindowEndTests.cs @@ -27,6 +27,7 @@ namespace Darling.Tests; /// behavioural half, which seeds a sample at the end and runs the real detector, is /// AnomalyTileWindowEndTests in Lite.Tests. /// +[Trait("Reads", "Lite")] public sealed class AnomalyTileWindowEndTests { /// The closed spelling of the window end: a boundary sample is read. diff --git a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs index 96ed45f88d..2568b40c3b 100644 --- a/Darling/Darling.Tests/AsOfWindowAnchorTests.cs +++ b/Darling/Darling.Tests/AsOfWindowAnchorTests.cs @@ -35,6 +35,7 @@ namespace Darling.Tests; /// which reads carry the parameter. The live half — seed a past window, read it anchored, read it /// unanchored, prove the two disagree — is . /// +[Trait("Reads", "Lite")] public sealed class AsOfWindowAnchorTests { /* ── the resolver's contract ── */ @@ -242,6 +243,7 @@ public void TheSharedAnchorDescriptions_StateTheRule_WithinTheParameterBudget_An public void EveryDispatchedReadWhoseToolTakesAnAnchor_AdvertisesItInTheCatalog() { var missing = DarlingWebEndpoints.BuildReadDispatch().Keys + .Where(name => !DarlingWebEndpoints.WebOnlyReadNames.Contains(name)) // no tool behind it, and a keyed read has no window (#5241) .Where(ToolTakesAnAnchor) .Where(name => !DarlingWebEndpoints.CatalogDescriptors[name].Params.Any(p => p.Name == "as_of")) .OrderBy(n => n, StringComparer.Ordinal) @@ -281,10 +283,13 @@ anchor. What is left are the two permanent kinds. get_pvs_stats and get_fleet_ov result whose two halves describe different instants, which is worse than not offering it. get_store_metrics windows in days over the store's own growth series. get_read_latency windows in hours over the service's own read-duration telemetry, like get_store_metrics over the store's - own growth series. */ + own growth series. get_finops windows over the query-stats rows ending now and takes no anchor + (its description says so); the FinOps views are cost-oriented live reads. get_slow_reads windows in hours + back from now over the service's own slow-read telemetry, like get_read_latency, and takes no anchor. get_store_query_history windows in hours back from now over the store's own statement history and takes no anchor. */ var excluded = new[] { "get_pvs_stats", "get_fleet_overview", "get_store_metrics", "get_read_latency", + "get_finops", "get_slow_reads", "get_store_query_history", }; var unanchored = DarlingWebEndpoints.BuildReadDispatch().Keys @@ -555,6 +560,7 @@ private static bool ToolTakesAnAnchor(string readName) /// a demonstrably different answer, not the same one with more rows. /// [Collection("live-postgres")] +[Trait("Reads", "Lite")] public sealed class AsOfWindowAnchorLivePostgresTests { private const string ServerName = "darling-asof-anchor-e2e"; diff --git a/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs b/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs index fcda327969..af44a5fbd7 100644 --- a/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs +++ b/Darling/Darling.Tests/AvailabilityGroupsTabRefreshTests.cs @@ -35,7 +35,7 @@ public void Render_SameRowsAcrossTwoCalls_KeepsTheSameCardInstances() /* The pin the ruling asks for: two refreshes over identical rows must not hand the ItemsControl a new object per card. Before the fix, Render assigns a brand new List (brand new instances) every call, so this fails on the pre-#4238 code — proven once, below. */ - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); @@ -56,7 +56,7 @@ public void Render_SameRowsAcrossTwoCalls_KeepsTheSameCardInstances() [Fact] public void Render_ChangedValue_UpdatesInPlace_SameInstanceNewValue() { - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); @@ -80,7 +80,7 @@ public void Render_ChangedValue_UpdatesInPlace_SameInstanceNewValue() [Fact] public void Render_AddedOrRemovedAg_AddsOrRemovesExactlyOneCard() { - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); @@ -129,7 +129,7 @@ public void Render_UnchangedRows_ReplacesNoCardReplicaOrDatabaseInstances() reconcile every card, replica and database IN PLACE and never rebuild any of them. A regression that tears down and rebuilds rows -- or an O(n^2) loop that amounts to the same thing -- shows up here as a nonzero replaced count, independent of how loaded the runner is. */ - OnStaThread(() => + StaTestThread.Run(() => { var tab = new AvailabilityGroupsTab(); var topology = BuildTopology(agCount: 42, replicasPerAg: 2, dbRowsPerAg: 9, extraDbRowsOnFirst: 20); @@ -253,22 +253,4 @@ private static AgTopologyDatabaseRow Database(int serverId, string serverName, s RedoRateKbPerSec = 0, }; - /// WPF objects require STA; same shape as RawWindowFloorViewerPortTests / Lite.Tests' MainWindowAccessKeyTests. - private static void OnStaThread(Action body) - { - Exception? error = null; - var thread = new Thread(() => - { - try { body(); } - catch (Exception ex) { error = ex; } - }); - thread.SetApartmentState(ApartmentState.STA); - thread.Start(); - thread.Join(); - - if (error is not null) - { - throw error; - } - } } diff --git a/Darling/Darling.Tests/AwsAssumeRolePackageTests.cs b/Darling/Darling.Tests/AwsAssumeRolePackageTests.cs new file mode 100644 index 0000000000..4d70f21906 --- /dev/null +++ b/Darling/Darling.Tests/AwsAssumeRolePackageTests.cs @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using Xunit; + +namespace Darling.Tests; + +/// +/// The Darling service ships the assembly the AWS SDK loads to assume a role (#5431). +/// +/// The defect. Every RDS and Performance Insights client is built with a region only, so +/// credentials come from the SDK's default chain, and a role_arn profile selected through +/// AWS_PROFILE is part of that chain. The SDK resolves such a profile into +/// AssumeRoleAWSCredentials, which calls sts:AssumeRole through AWSSDK.SecurityToken +/// and loads that assembly by name at the moment it first needs credentials. The service carried +/// AWSSDK.Core, .RDS and .PI but not .SecurityToken, so the profile was found and +/// then failed with "Assembly AWSSDK.SecurityToken could not be found or loaded" before any call reached +/// AWS — a runtime failure on a path that compiles, and nothing in the service's own source names the type, +/// so a package that is simply absent is invisible to the compiler. +/// +/// What this pins. The package reference is what puts the assembly in the service's +/// deps.json and its publish folder, and the test project references the service, so the same +/// assembly is resolvable from this test's own output. Two arms: the service's deps.json lists it +/// (what the publish step copies from), and the type the SDK loads resolves by assembly-qualified name. +/// Removing the PackageReference from the service project fails both. +/// +/// What this cannot see. That a given profile reaches STS, or that the role's trust policy +/// allows the host: those are AWS-side and need credentials this suite never has. The publish folder itself +/// is not inspected here; the framework-dependent publish in the release workflow copies every +/// deps.json library, and the PR that added this checked it by publishing. +/// +public sealed class AwsAssumeRolePackageTests +{ + private const string Package = "AWSSDK.SecurityToken"; + + [Fact] + public void ServiceDepsJson_ListsTheSecurityTokenPackage() + { + var depsPath = Path.Combine( + AppContext.BaseDirectory, "PerformanceMonitor.Darling.Service.deps.json"); + + Assert.True( + File.Exists(depsPath), + $"the service's deps.json is not beside the test binaries ({depsPath}), so the test " + + "project no longer reaches the service's dependency closure and this arm proves nothing"); + + var deps = File.ReadAllText(depsPath); + + /* The library entry is "AWSSDK.SecurityToken/". Matching the slash keeps any other + mention of the package name from satisfying it. */ + Assert.Contains($"\"{Package}/", deps, StringComparison.Ordinal); + } + + [Fact] + public void TheStsClientTheSdkLoadsForAssumeRole_ResolvesFromTheServiceOutput() + { + var type = Type.GetType( + "Amazon.SecurityToken.AmazonSecurityTokenServiceClient, " + Package, + throwOnError: false); + + Assert.True( + type is not null, + $"{Package} cannot be loaded, so a role_arn profile fails with \"Assembly {Package} could " + + "not be found or loaded\" instead of calling sts:AssumeRole (#5431)"); + } +} diff --git a/Darling/Darling.Tests/AwsAssumedRoleCredentialsTests.cs b/Darling/Darling.Tests/AwsAssumedRoleCredentialsTests.cs new file mode 100644 index 0000000000..44759487a0 --- /dev/null +++ b/Darling/Darling.Tests/AwsAssumedRoleCredentialsTests.cs @@ -0,0 +1,530 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Amazon.Runtime.Endpoints; +using Amazon.SecurityToken; +using Amazon.SecurityToken.Model; +using PerformanceMonitor.Darling.Service.Targets; +using Xunit; + +namespace Darling.Tests; + +/// +/// The credentials class for a per-server AWS role (#5452): how often it calls STS, when it refreshes, what it serves +/// while a refresh runs or fails, what it sends, and which exception each failure becomes. No AWS and no network: a +/// fake STS and a clock the test moves. +/// +public sealed class AwsAssumedRoleCredentialsTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + private const string Sentinel = "sentinel-external-id-4417"; + + private static readonly RegionEndpoint UsEast1 = RegionEndpoint.USEast1; + + private static AwsAssumedRoleCredentials Make( + FakeSts sts, ManualTimeProvider clock, string? externalId = null, TimeSpan? deadline = null, + Func>? source = null, CapturingAwsLogger? logger = null, Func? installId = null) => + new(new AwsRoleKey(Role, externalId), UsEast1, sts.Factory, source ?? FakeSts.Source, installId, clock, deadline, logger); + + /* ---- how many STS calls ------------------------------------------------------------------------------ */ + + [Fact] + public async Task ManyCallersAtOnce_MakeOneStsCall() + { + var sts = new FakeSts(async (_, token) => + { + await Task.Delay(50, token); + return FakeSts.Grant(); + }); + var credentials = Make(sts, new ManualTimeProvider()); + + var all = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => credentials.GetCredentialsAsync())); + + Assert.Equal(1, sts.Calls); + Assert.All(all, c => Assert.Equal("ASIAEXAMPLEASSUMED", c.AccessKey)); + } + + [Fact] + public async Task ValidCredentials_AreServedWithNoNewCall_UntilFifteenMinutesBeforeExpiry() + { + var clock = new ManualTimeProvider(); + var sts = new FakeSts(); + var credentials = Make(sts, clock); + + await credentials.GetCredentialsAsync(); + clock.Advance(TimeSpan.FromMinutes(44)); + await credentials.GetCredentialsAsync(); + Assert.Equal(1, sts.Calls); + + clock.Advance(TimeSpan.FromMinutes(2)); + await credentials.GetCredentialsAsync(); + Assert.Equal(2, sts.Calls); + } + + [Fact] + public async Task ExpiryIsReceiptPlusDuration_NotTheResponsesExpiration() + { + var clock = new ManualTimeProvider(); + + /* An Expiration a minute away on AWS's clock would force a call per request if it were read; one in the past + likewise. Neither is read. */ + foreach (DateTime? expiration in new DateTime?[] { null, DateTime.UtcNow.AddMinutes(1), DateTime.UtcNow.AddDays(-1) }) + { + var sts = new FakeSts((_, _) => Task.FromResult(FakeSts.Grant(expiration: expiration))); + var credentials = Make(sts, clock); + + await credentials.GetCredentialsAsync(); + clock.Advance(TimeSpan.FromMinutes(30)); + await credentials.GetCredentialsAsync(); + Assert.Equal(1, sts.Calls); + clock.Advance(TimeSpan.FromMinutes(-30)); + } + } + + [Fact] + public void TheRefreshPointIsNeverSoonerThanTheMinimumIntervalAfterReceipt() + { + var received = new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero); + + Assert.Equal(received.AddMinutes(45), AwsAssumedRoleCredentials.RefreshPointFor(received, received.AddHours(1))); + Assert.Equal(received.AddMinutes(1), AwsAssumedRoleCredentials.RefreshPointFor(received, received.AddMinutes(5))); + Assert.Equal(received.AddMinutes(1), AwsAssumedRoleCredentials.RefreshPointFor(received, received.AddSeconds(10))); + } + + /* ---- serving without blocking ------------------------------------------------------------------------ */ + + [Fact] + public async Task PastTheRefreshPoint_OneCallerRefreshes_AndTheOthersKeepUsingTheValidCredentials() + { + var clock = new ManualTimeProvider(); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var blockNext = false; + var sts = new FakeSts(async (_, _) => + { + if (blockNext) + { + entered.TrySetResult(); + await release.Task; + return FakeSts.Grant("second-token"); + } + + return FakeSts.Grant("first-token"); + }); + var credentials = Make(sts, clock); + + var first = await credentials.GetCredentialsAsync(); + Assert.Equal("first-token", first.Token); + + clock.Advance(TimeSpan.FromMinutes(50)); + blockNext = true; + var refresher = credentials.GetCredentialsAsync(); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + + /* The refresh is in flight and holds the gate: these must come back at once, with the old credentials. */ + var served = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => credentials.GetCredentialsAsync())) + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.All(served, c => Assert.Equal("first-token", c.Token)); + Assert.False(refresher.IsCompleted); + + release.SetResult(); + Assert.Equal("second-token", (await refresher).Token); + Assert.Equal(2, sts.Calls); + } + + [Fact] + public async Task ACallerWithNothingValid_WaitsForTheRefresh_AndSharesItsAnswer() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sts = new FakeSts(async (_, _) => + { + await release.Task; + return FakeSts.Grant(); + }); + var credentials = Make(sts, new ManualTimeProvider()); + + var callers = Enumerable.Range(0, 4).Select(_ => credentials.GetCredentialsAsync()).ToList(); + await Task.Delay(100); + Assert.All(callers, c => Assert.False(c.IsCompleted)); + + release.SetResult(); + await Task.WhenAll(callers).WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(1, sts.Calls); + } + + /* ---- what is sent -------------------------------------------------------------------------------------- */ + + [Fact] + public async Task TheRequest_CarriesTheRole_AnHourOfDuration_AndTheSessionName() + { + var sts = new FakeSts(); + var credentials = Make(sts, new ManualTimeProvider(), installId: () => "0123abcd-4567-89ef-0123-456789abcdef"); + + await credentials.GetCredentialsAsync(); + + var request = Assert.Single(sts.Requests); + Assert.Equal(Role, request.RoleArn); + Assert.Equal(3600, request.DurationSeconds); + Assert.Equal("darling-0123abcd4567", request.RoleSessionName); + Assert.Null(request.ExternalId); + } + + [Fact] + public async Task TheExternalId_IsSentOnlyWhenSet() + { + var withId = new FakeSts(); + await Make(withId, new ManualTimeProvider(), Sentinel).GetCredentialsAsync(); + Assert.Equal(Sentinel, Assert.Single(withId.Requests).ExternalId); + + var blank = new FakeSts(); + await Make(blank, new ManualTimeProvider(), externalId: string.Empty).GetCredentialsAsync(); + Assert.Null(Assert.Single(blank.Requests).ExternalId); + } + + [Fact] + public async Task TheStsClientIsGivenASnapshotOfTheSourceCredentials() + { + var sts = new FakeSts(); + await Make(sts, new ManualTimeProvider()).GetCredentialsAsync(); + + var seen = Assert.Single(sts.SourceCredentialsSeen); + Assert.Equal("AKIAEXAMPLESOURCE", seen.AccessKey); + Assert.Equal("example-source-secret", seen.SecretKey); + } + + [Theory] + [InlineData("0123abcd-4567-89ef-0123-456789abcdef", "darling-0123abcd4567")] + [InlineData("0123ABCD456789EF0123456789ABCDEF", "darling-0123abcd4567")] + [InlineData(" 0123abcd-4567-89ef-0123-456789abcdef ", "darling-0123abcd4567")] + [InlineData("0a1b2c3d", "darling-0a1b2c3d")] + [InlineData("0A1B2C3D", "darling-0a1b2c3d")] + [InlineData(null, "darling-collector")] + [InlineData("", "darling-collector")] + [InlineData("abc", "darling-collector")] + [InlineData("0a1b2c3", "darling-collector")] + [InlineData("not-a-hex-install-id-at-all", "darling-collector")] + public void SessionName_IsDarlingAndTheInstallIdsHexDigits_OrTheFallback(string? installId, string expected) + { + var name = AwsAssumedRoleCredentials.SessionNameFor(installId); + + Assert.Equal(expected, name); + Assert.Matches(new Regex(@"^[\w+=,.@-]{2,64}$"), name); + } + + /* ---- the region of the call that triggers a refresh --------------------------------------------------- */ + + [Fact] + public async Task ARefresh_CallsStsInTheRegionOfTheCallThatTriggeredIt() + { + var clock = new ManualTimeProvider(); + var sts = new FakeSts(); + var credentials = Make(sts, clock); + + await credentials.ForRegion(RegionEndpoint.GetBySystemName("eu-west-1")).GetCredentialsAsync(); + clock.Advance(TimeSpan.FromMinutes(50)); + await credentials.ForRegion(RegionEndpoint.GetBySystemName("ap-southeast-2")).GetCredentialsAsync(); + + Assert.Equal(new[] { "eu-west-1", "ap-southeast-2" }, sts.Regions); + } + + [Theory] + [InlineData("us-east-1", "https://sts.us-east-1.amazonaws.com")] + [InlineData("cn-north-1", "https://sts.cn-north-1.amazonaws.com.cn")] + [InlineData("us-gov-west-1", "https://sts.us-gov-west-1.amazonaws.com")] + public void TheStsClientConfig_PointsAtTheRegionalEndpointOfTheTargetsOwnPartition(string region, string expectedUrl) + { + var config = AwsAssumedRoleCredentials.CreateStsConfig(RegionEndpoint.GetBySystemName(region)); + + var endpoint = config.DetermineServiceOperationEndpoint(new ServiceOperationEndpointParameters(new AssumeRoleRequest())); + + Assert.StartsWith(expectedUrl, endpoint.URL, StringComparison.Ordinal); + Assert.Equal(AwsAssumedRoleCredentials.StsTimeout, config.Timeout); + Assert.Equal(AwsAssumedRoleCredentials.StsMaxErrorRetry, config.MaxErrorRetry); + } + + /* ---- failure ------------------------------------------------------------------------------------------- */ + + [Fact] + public async Task AFailedRefresh_IsRememberedForAMinute_AndEachThrowIsANewException() + { + var clock = new ManualTimeProvider(); + var sts = new FakeSts((_, _) => throw FakeSts.Error("AccessDenied", "User is not authorized to perform: sts:AssumeRole")); + var credentials = Make(sts, clock); + + var first = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + var second = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + var third = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + + Assert.Equal(1, sts.Calls); + Assert.NotSame(first, second); + Assert.NotSame(second, third); + Assert.Equal(first.Message, second.Message); + Assert.Equal(AwsRoleAssumeKind.Denied, second.Kind); + Assert.Equal("AccessDenied", second.SourceErrorCode); + + clock.Advance(TimeSpan.FromSeconds(61)); + await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + Assert.Equal(2, sts.Calls); + } + + [Fact] + public async Task WhileAFailureIsRemembered_OldCredentialsWithMoreThanAMinuteLeftAreServed() + { + var clock = new ManualTimeProvider(); + var logger = new CapturingAwsLogger(); + var sts = new FakeSts(); + var credentials = Make(sts, clock, logger: logger); + await credentials.GetCredentialsAsync(); + + sts.Handler = (_, _) => throw FakeSts.Error("Throttling", "Rate exceeded"); + + /* Past the refresh point with ten minutes left: the refresh fails, the old credentials are served, and the failure is only logged. */ + clock.Advance(TimeSpan.FromMinutes(50)); + Assert.Equal("ASIAEXAMPLEASSUMED", (await credentials.GetCredentialsAsync()).AccessKey); + Assert.Equal(2, sts.Calls); + Assert.Contains("Warning", logger.All, StringComparison.Ordinal); + + /* Inside the minute of the remembered failure there is no new STS call. */ + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.Equal("ASIAEXAMPLEASSUMED", (await credentials.GetCredentialsAsync()).AccessKey); + Assert.Equal(2, sts.Calls); + + /* The failure has expired and the credentials have under a minute left: a new attempt, which fails, and the failure is thrown. */ + clock.Advance(TimeSpan.FromMinutes(8) + TimeSpan.FromSeconds(45)); + var thrown = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + Assert.Equal(AwsRoleAssumeKind.Transient, thrown.Kind); + Assert.Equal(3, sts.Calls); + + /* And the next throw, from the remembered failure, is a new exception with no new call. */ + clock.Advance(TimeSpan.FromSeconds(20)); + var again = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + Assert.NotSame(thrown, again); + Assert.Equal(thrown.Message, again.Message); + Assert.Equal(3, sts.Calls); + } + + [Fact] + public async Task ARememberedFailure_BelongsToItsRegion() + { + var sts = new FakeSts((_, _) => throw FakeSts.Error("RegionDisabledException", "STS is not active")); + var credentials = Make(sts, new ManualTimeProvider()); + + await Assert.ThrowsAsync(() => credentials.ForRegion(RegionEndpoint.EUSouth1).GetCredentialsAsync()); + await Assert.ThrowsAsync(() => credentials.ForRegion(RegionEndpoint.EUSouth1).GetCredentialsAsync()); + Assert.Equal(1, sts.Calls); + + sts.Handler = (_, _) => Task.FromResult(FakeSts.Grant()); + var other = await credentials.ForRegion(RegionEndpoint.USEast1).GetCredentialsAsync(); + Assert.Equal("ASIAEXAMPLEASSUMED", other.AccessKey); + Assert.Equal(2, sts.Calls); + } + + [Theory] + [InlineData("AccessDenied", AwsRoleAssumeKind.Denied, true)] + [InlineData("RegionDisabledException", AwsRoleAssumeKind.RegionDisabled, true)] + [InlineData("ExpiredToken", AwsRoleAssumeKind.SourceCredentials, false)] + [InlineData("InvalidClientTokenId", AwsRoleAssumeKind.SourceCredentials, false)] + [InlineData("SignatureDoesNotMatch", AwsRoleAssumeKind.SourceCredentials, false)] + [InlineData("Throttling", AwsRoleAssumeKind.Transient, false)] + [InlineData("ServiceUnavailable", AwsRoleAssumeKind.Transient, false)] + public async Task EachStsErrorCode_BecomesItsKind(string code, AwsRoleAssumeKind kind, bool isConfiguration) + { + var sts = new FakeSts((_, _) => throw FakeSts.Error(code, "raw message from AWS")); + + var ex = await Assert.ThrowsAsync(() => Make(sts, new ManualTimeProvider(), Sentinel).GetCredentialsAsync()); + + Assert.Equal(kind, ex.Kind); + Assert.Equal(isConfiguration, ex.IsConfiguration); + Assert.Equal(Role, ex.RoleArn); + Assert.True(ex.ExternalIdSet); + Assert.Equal(code, ex.SourceErrorCode); + Assert.Contains(Role, ex.Message, StringComparison.Ordinal); + Assert.Null(ex.InnerException); + Assert.Contains("AmazonSecurityTokenServiceException", ex.SourceExceptionType, StringComparison.Ordinal); + } + + [Fact] + public async Task ARegionDisabledExceptionType_IsRegionDisabled_WhateverItsCode() + { + var sts = new FakeSts((_, _) => throw new RegionDisabledException("STS is off here")); + + var ex = await Assert.ThrowsAsync(() => Make(sts, new ManualTimeProvider()).GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.RegionDisabled, ex.Kind); + Assert.Contains("us-east-1", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task TheDeniedText_SaysWhetherAnExternalIdWasSet_AndNeverTheValue() + { + var sts = new FakeSts((_, _) => throw FakeSts.Error("AccessDenied", "denied")); + + var withId = await Assert.ThrowsAsync(() => Make(sts, new ManualTimeProvider(), Sentinel).GetCredentialsAsync()); + var withoutId = await Assert.ThrowsAsync(() => Make(sts, new ManualTimeProvider()).GetCredentialsAsync()); + + Assert.Contains("(with the external ID set on this server)", withId.Message, StringComparison.Ordinal); + Assert.Contains("(without an external ID)", withoutId.Message, StringComparison.Ordinal); + Assert.DoesNotContain(Sentinel, withId.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task AHostWithNoCredentials_IsNoSourceIdentity_AndStsIsNeverCalled() + { + var sts = new FakeSts(); + var credentials = Make(sts, new ManualTimeProvider(), source: _ => throw new AmazonClientException("Unable to get IAM security credentials")); + + var ex = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.NoSourceIdentity, ex.Kind); + Assert.False(ex.IsConfiguration); + Assert.Contains("The monitoring host has no AWS credentials to assume role " + Role + " with.", ex.Message, StringComparison.Ordinal); + Assert.Equal(0, sts.Calls); + } + + [Fact] + public async Task ANoSourceIdentityMessage_IsAFixedSentence_WithTheSdkTypeAndCodeOnly_AndTheSdkTextGoesToTheServiceLog() + { + var logger = new CapturingAwsLogger(); + var sts = new FakeSts(); + var credentials = Make(sts, new ManualTimeProvider(), logger: logger, source: _ => throw new AmazonServiceException( + @"The profile 'corp-sso' could not be found in C:\Users\svc\.aws\credentials; SSO cache C:\Users\svc\.aws\sso\cache failed", + null, System.Net.HttpStatusCode.Forbidden) + { ErrorCode = "ProfileNotFound" }); + + var ex = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.NoSourceIdentity, ex.Kind); + Assert.Contains("(SDK exception Amazon.Runtime.AmazonServiceException, AWS error code ProfileNotFound)", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("corp-sso", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain(".aws", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("svc", ex.Message, StringComparison.Ordinal); + Assert.Contains("corp-sso", ex.SourceDetail, StringComparison.Ordinal); + Assert.Contains("corp-sso", logger.All, StringComparison.Ordinal); + } + + [Fact] + public async Task ATransientMessage_IsAFixedSentence_WithTheSdkTypeAndCodeOnly() + { + var sts = new FakeSts((_, _) => throw new AmazonServiceException( + @"Unable to reach http://169.254.170.2/v2/credentials/abc from C:\ProgramData\aws\config", + null, System.Net.HttpStatusCode.ServiceUnavailable) + { ErrorCode = "ServiceUnavailable" }); + var credentials = Make(sts, new ManualTimeProvider()); + + var ex = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.Transient, ex.Kind); + Assert.Contains("(SDK exception Amazon.Runtime.AmazonServiceException, AWS error code ServiceUnavailable)", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("169.254", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("ProgramData", ex.Message, StringComparison.Ordinal); + Assert.Contains("169.254.170.2", ex.SourceDetail, StringComparison.Ordinal); + } + + [Fact] + public async Task ACredentialSourceThatNeverCompletes_FailsWithinTheDeadline_AndASecondCallerFailsWithinTheSameBound() + { + var sts = new FakeSts(); + var credentials = Make(sts, new ManualTimeProvider(), deadline: TimeSpan.FromMilliseconds(200), + source: _ => new TaskCompletionSource().Task); + + var first = Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + var second = Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + + var firstFailure = await first.WaitAsync(TimeSpan.FromSeconds(10)); + var secondFailure = await second.WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(AwsRoleAssumeKind.Transient, firstFailure.Kind); + Assert.Contains("did not answer within", firstFailure.Message, StringComparison.Ordinal); + Assert.Equal(AwsRoleAssumeKind.Transient, secondFailure.Kind); + Assert.Equal(0, sts.Calls); + } + + [Fact] + public async Task ASlowSts_IsCutOffAtTheDeadline_AndIsATransientFailure() + { + var sts = new FakeSts(async (_, token) => + { + await Task.Delay(Timeout.Infinite, token); + return FakeSts.Grant(); + }); + var credentials = Make(sts, new ManualTimeProvider(), deadline: TimeSpan.FromMilliseconds(100)); + + var ex = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()).WaitAsync(TimeSpan.FromSeconds(10)); + + Assert.Equal(AwsRoleAssumeKind.Transient, ex.Kind); + Assert.Contains("did not answer within", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ACallerWhoCancels_GetsTheCancellation_NotARememberedFailure() + { + var sts = new FakeSts(async (_, token) => + { + await Task.Delay(Timeout.Infinite, token); + return FakeSts.Grant(); + }); + var credentials = Make(sts, new ManualTimeProvider()); + using var cts = new CancellationTokenSource(); + + var pending = credentials.GetCredentialsAsync(UsEast1, cts.Token); + await Task.Delay(50); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => pending); + + sts.Handler = (_, _) => Task.FromResult(FakeSts.Grant()); + Assert.Equal("ASIAEXAMPLEASSUMED", (await credentials.GetCredentialsAsync()).AccessKey); + } + + [Fact] + public async Task AnEmptyResponse_IsATransientFailure() + { + var sts = new FakeSts((_, _) => Task.FromResult(new AssumeRoleResponse())); + + var ex = await Assert.ThrowsAsync(() => Make(sts, new ManualTimeProvider()).GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.Transient, ex.Kind); + } + + /* ---- the SDK seam -------------------------------------------------------------------------------------- */ + + [Fact] + public async Task ARealRdsClientOverTheCredentials_SurfacesTheExceptionToWhoeverWalksTheChain() + { + var sts = new FakeSts((_, _) => throw FakeSts.Error("AccessDenied", "denied")); + var credentials = Make(sts, new ManualTimeProvider(), Sentinel); + + using var rds = new Amazon.RDS.AmazonRDSClient(credentials.ForRegion(UsEast1), UsEast1); + var thrown = await Record.ExceptionAsync(() => rds.DescribeDBInstancesAsync(new Amazon.RDS.Model.DescribeDBInstancesRequest())); + + Assert.NotNull(thrown); + var found = AwsRoleAssumeException.Find(thrown); + Assert.NotNull(found); + Assert.Equal(AwsRoleAssumeKind.Denied, found!.Kind); + Assert.Equal(1, sts.Calls); + } + + [Fact] + public void Find_WalksTheInnerChain_AndAnAggregate() + { + var inner = new AwsRoleCredentialCache(AwsRoleAllowlist.Empty); + var thrown = Assert.Throws(() => inner.For(new AwsRoleKey(Role, null), "us-east-1")); + + Assert.Same(thrown, AwsRoleAssumeException.Find(thrown)); + Assert.Same(thrown, AwsRoleAssumeException.Find(new InvalidOperationException("wrapped", new InvalidOperationException("twice", thrown)))); + Assert.Same(thrown, AwsRoleAssumeException.Find(new AggregateException(new InvalidOperationException("a"), new InvalidOperationException("b", thrown)))); + Assert.Null(AwsRoleAssumeException.Find(new InvalidOperationException("none"))); + Assert.Null(AwsRoleAssumeException.Find(null)); + } +} diff --git a/Darling/Darling.Tests/AwsExternalIdNeverPrintedTests.cs b/Darling/Darling.Tests/AwsExternalIdNeverPrintedTests.cs new file mode 100644 index 0000000000..9e28ceff10 --- /dev/null +++ b/Darling/Darling.Tests/AwsExternalIdNeverPrintedTests.cs @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using PerformanceMonitor.Darling.Service.Targets; +using Xunit; + +namespace Darling.Tests; + +/// +/// The external ID of a per-server AWS role is a secret (#5452): it never appears in a message, an exception's text, a +/// key's text or a log line, whatever the SDK repeats back. Each test builds a fake STS or credential source whose +/// error text repeats a sentinel ID (as written, URL-encoded and in other case) and then looks for it everywhere the +/// code under test can put it. +/// +public sealed class AwsExternalIdNeverPrintedTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + + /// Uses every character the external ID alphabet allows, so an encoder changes it. + private const string Sentinel = "sent+inel=id,7.4@x:y/z-9"; + + private static string Echo(string text) => + $"{text} [request externalId={Sentinel} encoded={Uri.EscapeDataString(Sentinel)} upper={Sentinel.ToUpperInvariant()}]"; + + private static void AssertClean(string? text) + { + Assert.NotNull(text); + Assert.DoesNotContain(Sentinel, text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Uri.EscapeDataString(Sentinel), text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("sent+inel", text, StringComparison.OrdinalIgnoreCase); + } + + private static void AssertClean(AwsRoleAssumeException ex) + { + AssertClean(ex.Message); + AssertClean(ex.ToString()); + AssertClean(ex.SourceErrorCode ?? string.Empty); + AssertClean(ex.SourceExceptionType ?? string.Empty); + AssertClean(ex.SourceDetail ?? string.Empty); + AssertClean(ex.StackTrace ?? string.Empty); + Assert.Null(ex.InnerException); + Assert.Empty(ex.Data); + } + + private static AwsAssumedRoleCredentials Make( + FakeSts sts, ManualTimeProvider clock, CapturingAwsLogger? logger = null, Func>? source = null) => + new(new AwsRoleKey(Role, Sentinel), RegionEndpoint.USEast1, sts.Factory, source ?? FakeSts.Source, null, clock, null, logger); + + /* ---- the key ------------------------------------------------------------------------------------------- */ + + [Fact] + public void TheKeysText_NamesTheRole_AndSaysWhetherAnIdIsSet() + { + var withId = new AwsRoleKey(Role, Sentinel); + var withoutId = new AwsRoleKey(Role, null); + + Assert.Equal("AwsRoleKey { RoleArn = " + Role + ", ExternalId = set }", withId.ToString()); + Assert.Equal("AwsRoleKey { RoleArn = " + Role + ", ExternalId = none }", withoutId.ToString()); + AssertClean($"{withId}"); + AssertClean(string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}", withId)); + AssertClean(new[] { withId }.Select(k => k.ToString()).Single()); + } + + [Fact] + public void TheKeyStillComparesByTheValueOfTheId() + { + Assert.Equal(new AwsRoleKey(Role, Sentinel), new AwsRoleKey(Role, Sentinel)); + Assert.NotEqual(new AwsRoleKey(Role, Sentinel), new AwsRoleKey(Role, Sentinel.ToUpperInvariant())); + Assert.NotEqual(new AwsRoleKey(Role, Sentinel), new AwsRoleKey(Role, null)); + Assert.Equal(Sentinel, new AwsRoleKey(Role, Sentinel).ExternalId); + } + + /* ---- every failure path -------------------------------------------------------------------------------- */ + + [Theory] + [InlineData("AccessDenied")] + [InlineData("RegionDisabledException")] + [InlineData("ExpiredToken")] + [InlineData("InvalidClientTokenId")] + [InlineData("SignatureDoesNotMatch")] + [InlineData("ValidationError")] + [InlineData("Throttling")] + public async Task AnStsErrorThatRepeatsTheId_LeavesItOutOfEverythingTheCodePrints(string code) + { + var logger = new CapturingAwsLogger(); + var sts = new FakeSts((_, _) => throw FakeSts.Error(code, Echo("Request failed for the call"))); + var credentials = Make(sts, new ManualTimeProvider(), logger); + + var first = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + var cached = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + + AssertClean(first); + AssertClean(cached); + AssertClean(logger.All); + Assert.True(first.ExternalIdSet); + Assert.Equal(1, sts.Calls); + } + + [Fact] + public async Task AnErrorCodeThatRepeatsTheId_IsScrubbedToo() + { + var sts = new FakeSts((_, _) => throw FakeSts.Error(Echo("InvalidClientTokenId"), "denied")); + + var ex = await Assert.ThrowsAsync(() => Make(sts, new ManualTimeProvider()).GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.SourceCredentials, ex.Kind); + AssertClean(ex); + } + + [Fact] + public async Task ASourceFailureThatRepeatsTheId_LeavesItOutOfTheMessage() + { + var logger = new CapturingAwsLogger(); + var sts = new FakeSts(); + var credentials = Make(sts, new ManualTimeProvider(), logger, + source: _ => throw new AmazonClientException(Echo("Unable to get credentials"))); + + var ex = await Assert.ThrowsAsync(() => credentials.GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.NoSourceIdentity, ex.Kind); + AssertClean(ex); + AssertClean(logger.All); + } + + [Fact] + public async Task ANonServiceExceptionThatRepeatsTheId_IsScrubbed() + { + var sts = new FakeSts((_, _) => throw new InvalidOperationException(Echo("socket closed"))); + + var ex = await Assert.ThrowsAsync(() => Make(sts, new ManualTimeProvider()).GetCredentialsAsync()); + + Assert.Equal(AwsRoleAssumeKind.Transient, ex.Kind); + AssertClean(ex); + Assert.Contains("(SDK exception System.InvalidOperationException)", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("socket closed", ex.Message, StringComparison.Ordinal); + Assert.Contains("socket closed", ex.SourceDetail, StringComparison.Ordinal); + } + + [Fact] + public async Task TheCredentialsHandedToAClient_CarryNoIdInTheirText() + { + var sts = new FakeSts(); + var credentials = Make(sts, new ManualTimeProvider()); + + var granted = await credentials.GetCredentialsAsync(); + + AssertClean(credentials.ToString()); + AssertClean(credentials.ForRegion(RegionEndpoint.USEast1).ToString()); + Assert.Equal(Sentinel, Assert.Single(sts.Requests).ExternalId); + Assert.NotNull(granted); + } + + [Fact] + public async Task ThroughARealRdsClient_TheExceptionThatReachesTheCaller_IsClean() + { + var sts = new FakeSts((_, _) => throw FakeSts.Error("AccessDenied", Echo("User is not authorized"))); + var credentials = Make(sts, new ManualTimeProvider()); + + using var rds = new Amazon.RDS.AmazonRDSClient(credentials.ForRegion(RegionEndpoint.USEast1), RegionEndpoint.USEast1); + var thrown = await Record.ExceptionAsync(() => rds.DescribeDBInstancesAsync(new Amazon.RDS.Model.DescribeDBInstancesRequest())); + + Assert.NotNull(thrown); + AssertClean(thrown!.ToString()); + AssertClean(thrown.Message); + var found = AwsRoleAssumeException.Find(thrown); + Assert.NotNull(found); + AssertClean(found!); + } + + [Fact] + public void TheTwoRefusalsThatNeverReachStsAreClean() + { + var cache = new AwsRoleCredentialCache(AwsRoleAllowlist.Empty); + var notAllowed = Assert.Throws(() => cache.For(new AwsRoleKey(Role, Sentinel), "us-east-1")); + AssertClean(notAllowed); + + var allowed = new AwsRoleCredentialCache(AwsRoleAllowlist.From(new[] { "123456789012" })); + var mismatch = Assert.Throws(() => allowed.For(new AwsRoleKey(Role, Sentinel), "cn-north-1")); + AssertClean(mismatch); + Assert.True(notAllowed.ExternalIdSet); + Assert.True(mismatch.ExternalIdSet); + } + + [Fact] + public void ScrubRemovesEveryFormOfTheId_AndLeavesTextWithoutOneAlone() + { + Assert.Equal("none here", AwsRoleAssumeException.Scrub("none here", Sentinel)); + Assert.Equal("anything", AwsRoleAssumeException.Scrub("anything", null)); + Assert.Equal("anything", AwsRoleAssumeException.Scrub("anything", string.Empty)); + AssertClean(AwsRoleAssumeException.Scrub(Echo("text"), Sentinel)); + AssertClean(AwsRoleAssumeException.Scrub("value " + System.Net.WebUtility.HtmlEncode(Sentinel) + " end", Sentinel)); + } + + /* ---- the worker's own outputs --------------------------------------------------------------------------- */ + + private const string InstanceHost = "solo.abc123.us-east-1.rds.amazonaws.com"; + + [Theory] + [InlineData("AccessDenied")] + [InlineData("RegionDisabledException")] + [InlineData("ExpiredToken")] + [InlineData("Throttling")] + public async Task TheWorkersCollectionLogTextAndWarningLine_NeverCarryTheId(string code) + { + // An STS error that repeats the external ID in its message, driven through the real RDS reader and a real client. + var sts = new FakeSts((_, _) => throw FakeSts.Error(code, Echo("denied for role " + Role))); + var cacheLog = new CapturingAwsLogger(); + var cache = new AwsRoleCredentialCache(AwsRoleAllowlist.From(new[] { Role }), null, sts.Factory, FakeSts.Source, new ManualTimeProvider(), null, cacheLog); + var ingestor = new RdsCpuIngestor( + Npgsql.NpgsqlDataSource.Create("Host=localhost;Port=1;Database=unused"), + verifier: RdsEndpointVerifier.ForTests(enforce: true, loginProbe: (_, _) => Task.FromResult(null)), + roles: cache); + + var thrown = await Record.ExceptionAsync( + () => ingestor.IngestAsync(1, "s", InstanceHost, "Host=" + InstanceHost, new AwsRoleKey(Role, Sentinel), System.Threading.CancellationToken.None)); + + var fault = Assert.IsType(thrown); + AssertClean(fault); + + // What the worker writes for a refused role: the collection_log text and the warning line. + var workerLog = new CapturingAwsLogger(); + var rowText = PerformanceMonitor.Darling.Service.DarlingWorker.AwsRoleFaultNote(workerLog, "alpha-pg-01", "pg_plan_capture", fault); + AssertClean(rowText); + AssertClean(workerLog.All); + + // The ERROR arm writes the exception's message too; the credentials class logged one warning for the failed refresh. + AssertClean(fault.Message); + AssertClean(cacheLog.All); + } +} diff --git a/Darling/Darling.Tests/AwsPerServerRoleLiveTests.cs b/Darling/Darling.Tests/AwsPerServerRoleLiveTests.cs new file mode 100644 index 0000000000..611053097b --- /dev/null +++ b/Darling/Darling.Tests/AwsPerServerRoleLiveTests.cs @@ -0,0 +1,569 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/* #1776 own-store: each fact mints its own scratch database through ScratchPostgres and never touches another one. + The login role a fact needs is created for it and dropped in its cleanup. */ + +/// +/// The per-server AWS role (#5452) against a store: the rung on a store that stopped below it and on a fresh one, the +/// table's check, the column ACL, the 17-argument edit function and its 15-argument wrapper, and the trigger rule that +/// holds every role but the owner. +/// +public sealed class AwsPerServerRoleLiveTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + private const string OtherRole = "arn:aws:iam::123456789012:role/darling-other"; + private const string External = "tenant-1234"; + private const string OtherExternal = "tenant-5678"; + + private static readonly string RolePassword = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + + private static string? BaseConnectionString() => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task TextAsync(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + var value = await command.ExecuteScalarAsync(ct); + return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture); + } + + private static string Lit(string? value) => + value is null ? "NULL" : "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; + + private static async Task OpenOwnerAsync(ScratchPostgres scratch, CancellationToken ct) + { + var connection = new NpgsqlConnection( + new NpgsqlConnectionStringBuilder(scratch.ConnectionString) { SearchPath = PgSchemaGenerator.SearchPath }.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + return connection; + } + + private static Task InsertServerAsync(NpgsqlConnection connection, int id, string? role, string? externalId, CancellationToken ct) => + ExecAsync(connection, + "INSERT INTO config.config_monitored_servers (server_id, name, host, database, auth, username, engine, port, aws_role_arn, aws_external_id) " + + $"VALUES ({id}, 'srv-{id}', 'host-{id}', 'postgres', 'sql', 'monitor', 'postgres', 5432, {Lit(role)}, {Lit(externalId)})", ct); + + private static Task StoredAsync(NpgsqlConnection connection, int id, CancellationToken ct) => + TextAsync(connection, + "SELECT COALESCE(aws_role_arn, '') || ' | ' || COALESCE(aws_external_id, '') || ' | ' || aws_external_id_set::text " + + $"FROM config.config_monitored_servers WHERE server_id = {id}", ct); + + private static async Task RefusalAsync(Func action) + { + try + { + await action(); + return null; + } + catch (PostgresException ex) + { + return ex.SqlState; + } + } + + private static async Task TokenAsync(NpgsqlConnection connection, int id, CancellationToken ct) => + (await TextAsync(connection, + $"SELECT to_char(modified_at, 'YYYY-MM-DD HH24:MI:SS.US') FROM config.config_monitored_servers WHERE server_id = {id}", ct))!; + + /// One call of the 17-argument function (the AWS pair last), returning its outcome text. + private static async Task EditAsync( + NpgsqlConnection connection, int id, string columns, string? role, string? externalId, CancellationToken ct) + { + var token = await TokenAsync(connection, id, ct); + return await TextAsync(connection, + $"SELECT outcome FROM config.edit_monitored_server({id}, '{token}'::timestamp, ARRAY[{columns}]::text[], " + + $"NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, {Lit(role)}, {Lit(externalId)})", ct); + } + + /// One call of the 15-argument wrapper: no AWS arguments, but the caller may still NAME the AWS columns. + private static async Task LegacyEditAsync( + NpgsqlConnection connection, int id, string columns, string? name, CancellationToken ct) + { + var token = await TokenAsync(connection, id, ct); + return await TextAsync(connection, + $"SELECT outcome FROM config.edit_monitored_server({id}, '{token}'::timestamp, ARRAY[{columns}]::text[], " + + $"{Lit(name)}, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", ct); + } + + private static async Task OpenAsRoleAsync(ScratchPostgres scratch, string role, CancellationToken ct) + { + var connection = new NpgsqlConnection(new NpgsqlConnectionStringBuilder(scratch.ConnectionString) + { + Username = role, + Password = RolePassword, + SearchPath = PgSchemaGenerator.SearchPath, + }.ConnectionString); + await connection.OpenAsync(ct); + return connection; + } + + private static async Task CreateLoginRoleAsync(NpgsqlConnection owner, CancellationToken ct) + { + var role = "aws_role_" + Guid.NewGuid().ToString("N")[..8]; + await ExecAsync(owner, $"CREATE ROLE {role} LOGIN NOSUPERUSER PASSWORD '{RolePassword}'", ct); + await ExecAsync(owner, $"GRANT USAGE ON SCHEMA config TO {role}", ct); + return role; + } + + private static Task DropLoginRoleAsync(NpgsqlConnection cleanup, string? role) => + role is null + ? Task.CompletedTask + : ExecAsync(cleanup, $"DROP OWNED BY {role}; DROP ROLE IF EXISTS {role}", CancellationToken.None); + + /* ---- the rung -------------------------------------------------------------------------------------- */ + + [Fact] + public async Task TheRung_AppliesToAStoreThatStoppedBelowIt_AndToAFreshOne_AndARerunIsANoOp() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + + /* A fresh store took every rung in order, this one included. */ + Assert.Equal("3", await TextAsync(owner, + "SELECT count(*) FROM information_schema.columns WHERE table_schema = 'config' AND table_name = 'config_monitored_servers' " + + "AND column_name IN ('aws_role_arn', 'aws_external_id', 'aws_external_id_set')", ct)); + /* The store's top rung, not this rung's: V170 (#5495) landed above it. */ + Assert.Equal(StorageVersion.SchemaVersion.ToString(CultureInfo.InvariantCulture), + await TextAsync(owner, "SELECT max(version) FROM darling_schema_version", ct)); + Assert.Equal(1L.ToString(CultureInfo.InvariantCulture), await TextAsync(owner, + "SELECT count(*) FROM pg_constraint WHERE conname = 'config_monitored_servers_aws_role_check'", ct)); + Assert.True(await ProbeSentinelAsync(owner, ct), "the viewer's probe must see the role column on a migrated store"); + + /* A V168 store: the columns gone, the stamp rolled back, a server already registered. */ + await InsertServerAsync(owner, 8101, null, null, ct); + await ExecAsync(owner, "ALTER TABLE config.config_monitored_servers DROP COLUMN aws_external_id_set, DROP COLUMN aws_external_id, DROP COLUMN aws_role_arn", ct); + await ExecAsync(owner, "DELETE FROM darling_schema_version WHERE version >= 169", ct); + Assert.False(await ProbeSentinelAsync(owner, ct), "the probe must not see the role column once it is gone"); + + await PgMigrations.MigrateAsync(owner, ct); + + Assert.True(await ProbeSentinelAsync(owner, ct)); + Assert.Equal(" | | false", await StoredAsync(owner, 8101, ct)); + Assert.Equal(StorageVersion.SchemaVersion.ToString(CultureInfo.InvariantCulture), await TextAsync(owner, "SELECT max(version) FROM darling_schema_version", ct)); + + /* Running the rung's text again changes nothing and raises nothing. */ + var rung = PgMigrations.Scripts.Single(s => s.Version == AwsPerServerRoleRungTests.RungVersion).Sql; + await ExecAsync(owner, rung, ct); + await ExecAsync(owner, rung, ct); + Assert.Equal(" | | false", await StoredAsync(owner, 8101, ct)); + Assert.Equal("1", await TextAsync(owner, + "SELECT count(*) FROM pg_constraint WHERE conname = 'config_monitored_servers_aws_role_check'", ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (_, _) => Task.CompletedTask); + } + } + + private static async Task ProbeSentinelAsync(NpgsqlConnection connection, CancellationToken ct) + { + /* This rung's own sentinel by ordinal: a newer rung's sentinel (V170's) is now the last one, and it stays true here. */ + await using var command = new NpgsqlCommand(ViewerDataService.StoreSchemaProbeSql, connection); + await using var reader = await command.ExecuteReaderAsync(ct); + Assert.True(await reader.ReadAsync(ct)); + return reader.GetBoolean(AwsPerServerRoleRungTests.ProbeOrdinal); + } + + /* ---- the check ------------------------------------------------------------------------------------- */ + + [Fact] + public async Task TheCheck_RefusesAnIdWithNoRole_ABadArn_AndASpace_AndAcceptsTheCorpus() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + var id = 8200; + + /* Refused with 23514 (check_violation): an ID with no role, a role that is not an IAM role ARN, a role with a + space, an ID with a space, an ID that is too short. */ + foreach (var (role, externalId) in new (string?, string?)[] + { + (null, External), + ("not-an-arn", null), + ("arn:aws:iam::123456789012:user/darling", null), + ("arn:aws:iam::12345:role/darling", null), + ("arn:aws:iam::123456789012:role/has space", null), + ("arn:aws:iam::123456789012:role/", null), + (Role, "has space"), + (Role, "x"), + (Role, new string('x', 1225)), + }) + { + id++; + var state = await RefusalAsync(() => InsertServerAsync(owner, id, role, externalId, ct)); + Assert.Equal("23514", state); + } + + Assert.Equal("0", await TextAsync(owner, "SELECT count(*) FROM config.config_monitored_servers", ct)); + + /* Accepted: no role, a role, a role with an ID, the other partitions, a path, and the longest ID. */ + foreach (var (role, externalId) in new (string?, string?)[] + { + (null, null), + (Role, null), + (Role, External), + ("arn:aws-cn:iam::123456789012:role/darling", null), + ("arn:aws-us-gov:iam::123456789012:role/darling", "ab"), + ("arn:aws:iam::123456789012:role/monitoring/team/darling-monitor", null), + ("arn:aws:iam::123456789012:role/name_with+=,.@-chars", "A-b_C+d=e,f.g@h:i/j-k"), + (Role, new string('x', 1224)), + }) + { + id++; + await InsertServerAsync(owner, id, role, externalId, ct); + } + + Assert.Equal("8", await TextAsync(owner, "SELECT count(*) FROM config.config_monitored_servers", ct)); + + /* The generated flag follows the ID, and an update that would leave an ID with no role is refused too. */ + Assert.Equal("false", await TextAsync(owner, "SELECT aws_external_id_set::text FROM config.config_monitored_servers WHERE server_id = 8211", ct)); + Assert.Equal("true", await TextAsync(owner, "SELECT aws_external_id_set::text FROM config.config_monitored_servers WHERE server_id = 8212", ct)); + Assert.Equal("23514", await RefusalAsync(() => + ExecAsync(owner, "UPDATE config.config_monitored_servers SET aws_role_arn = NULL WHERE server_id = 8212", ct))); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (_, _) => Task.CompletedTask); + } + } + + /* ---- the column ACL -------------------------------------------------------------------------------- */ + + [Fact] + public async Task AReadOnlyRole_ReadsTheRoleAndTheFlag_ButGets42501OnTheExternalId() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + string? login = null; + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + await InsertServerAsync(owner, 8301, Role, External, ct); + + /* The grant is the one the managed batch and the script give viewer and mcp: the non-secret list, from the ACL. */ + login = await CreateLoginRoleAsync(owner, ct); + var table = DarlingManagedRoles.ViewerRestrictedConfigTables.Single(t => t.Table == "config_monitored_servers"); + await ExecAsync(owner, $"GRANT SELECT ({string.Join(", ", table.NonSecretColumns)}) ON config.config_monitored_servers TO {login}", ct); + + await using var reader = await OpenAsRoleAsync(scratch, login, ct); + Assert.Equal($"{Role} | true", + await TextAsync(reader, "SELECT aws_role_arn || ' | ' || aws_external_id_set::text FROM config.config_monitored_servers WHERE server_id = 8301", ct)); + + Assert.Equal("42501", await RefusalAsync(() => + TextAsync(reader, "SELECT aws_external_id FROM config.config_monitored_servers WHERE server_id = 8301", ct))); + Assert.Equal("42501", await RefusalAsync(() => + TextAsync(reader, "SELECT * FROM config.config_monitored_servers WHERE server_id = 8301", ct))); + + bodySucceeded = true; + } + finally + { + var loginToDrop = login; + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (cleanup, _) => DropLoginRoleAsync(cleanup, loginToDrop)); + } + } + + /* ---- the edit function and its wrapper -------------------------------------------------------------- */ + + [Fact] + public async Task TheEditFunction_SetsKeepsChangesAndClearsTheRole_AndAnswersTheTwoNewOutcomes() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + await ExecAsync(owner, DarlingManagedRoles.BuildEditMonitoredServerFunctionSql("config"), ct); + await ExecAsync(owner, DarlingManagedRoles.BuildEditMonitoredServerLegacyWrapperSql("config"), ct); + await InsertServerAsync(owner, 8401, null, null, ct); + + /* Setting a role with an ID. */ + Assert.Equal("saved", await EditAsync(owner, 8401, "'aws_role_arn', 'aws_external_id'", Role, External, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8401, ct)); + + /* Left out of p_columns, the role and the ID are kept, whatever values ride along unnamed. */ + Assert.Equal("saved", await EditAsync(owner, 8401, string.Empty, OtherRole, OtherExternal, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8401, ct)); + + /* A new role over a stored ID, with no ID named, would carry the old ID along unseen: refused, nothing written. */ + Assert.Equal("external_id_needed", await EditAsync(owner, 8401, "'aws_role_arn'", OtherRole, null, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8401, ct)); + + /* The same role again is not a change, so it needs no ID. */ + Assert.Equal("saved", await EditAsync(owner, 8401, "'aws_role_arn'", Role, null, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8401, ct)); + + /* A new role with the ID sent again, and with the ID cleared (named, NULL). */ + Assert.Equal("saved", await EditAsync(owner, 8401, "'aws_role_arn', 'aws_external_id'", OtherRole, OtherExternal, ct)); + Assert.Equal($"{OtherRole} | {OtherExternal} | true", await StoredAsync(owner, 8401, ct)); + Assert.Equal("saved", await EditAsync(owner, 8401, "'aws_role_arn', 'aws_external_id'", Role, null, ct)); + Assert.Equal($"{Role} | | false", await StoredAsync(owner, 8401, ct)); + + /* The ID alone, for the role that is stored. */ + Assert.Equal("saved", await EditAsync(owner, 8401, "'aws_external_id'", null, External, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8401, ct)); + + /* An ID with a cleared role: refused (the role is named NULL and an ID rides along). */ + Assert.Equal("external_id_needs_role", await EditAsync(owner, 8401, "'aws_role_arn', 'aws_external_id'", null, External, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8401, ct)); + + /* Clearing is explicit: the role named with NULL, which takes the ID with it. */ + Assert.Equal("saved", await EditAsync(owner, 8401, "'aws_role_arn'", null, null, ct)); + Assert.Equal(" | | false", await StoredAsync(owner, 8401, ct)); + + /* An ID named for a server that has no role: refused, and a plain edit of another column is unaffected. */ + Assert.Equal("external_id_needs_role", await EditAsync(owner, 8401, "'aws_external_id'", null, External, ct)); + Assert.Equal("saved", await EditAsync(owner, 8401, "'aws_external_id'", null, null, ct)); + Assert.Equal(" | | false", await StoredAsync(owner, 8401, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (_, _) => Task.CompletedTask); + } + } + + [Fact] + public async Task TheFifteenArgumentWrapper_KeepsTheRole_AndACallerNamingTheAwsColumnsCannotChangeOrClearIt() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + await ExecAsync(owner, DarlingManagedRoles.BuildEditMonitoredServerFunctionSql("config"), ct); + await ExecAsync(owner, DarlingManagedRoles.BuildEditMonitoredServerLegacyWrapperSql("config"), ct); + await InsertServerAsync(owner, 8501, Role, External, ct); + + /* An old-shape edit of another column leaves the role and the ID alone. */ + Assert.Equal("saved", await LegacyEditAsync(owner, 8501, "'name'", "renamed", ct)); + Assert.Equal("renamed", await TextAsync(owner, "SELECT name FROM config.config_monitored_servers WHERE server_id = 8501", ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8501, ct)); + + /* Naming the AWS columns in p_columns does nothing: there are no arguments to carry a value, and the names are + stripped, so the wrapper cannot clear the role or the ID. The rest of the edit still applies. */ + Assert.Equal("saved", await LegacyEditAsync(owner, 8501, "'aws_role_arn', 'aws_external_id', 'name'", "renamed-again", ct)); + Assert.Equal("renamed-again", await TextAsync(owner, "SELECT name FROM config.config_monitored_servers WHERE server_id = 8501", ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8501, ct)); + + Assert.Equal("saved", await LegacyEditAsync(owner, 8501, "'aws_role_arn'", null, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8501, ct)); + Assert.Equal("saved", await LegacyEditAsync(owner, 8501, "'aws_external_id'", null, ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8501, ct)); + + /* A NULL column list is an empty one. */ + var token = await TokenAsync(owner, 8501, ct); + Assert.Equal("saved", await TextAsync(owner, + $"SELECT outcome FROM config.edit_monitored_server(8501, '{token}'::timestamp, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", ct)); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8501, ct)); + + /* The two functions are two signatures of one name, and the wrapper is the definer-pinned one. */ + Assert.Equal("2", await TextAsync(owner, + "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = 'config' AND p.proname = 'edit_monitored_server'", ct)); + Assert.Equal("True", await TextAsync(owner, + "SELECT bool_and(prosecdef AND proconfig @> ARRAY['search_path=pg_catalog, pg_temp']) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace " + + "WHERE n.nspname = 'config' AND p.proname = 'edit_monitored_server'", ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (_, _) => Task.CompletedTask); + } + } + + /* ---- the trigger rule ------------------------------------------------------------------------------ */ + + [Fact] + public async Task ALoginThatOnlyExecutes_GetsInvalidValueForAnEmptyRequiredField_OnBothSignatures_AndTheRowIsUnchanged() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + string? login = null; + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + await ExecAsync(owner, DarlingManagedRoles.BuildEditMonitoredServerFunctionSql("config"), ct); + await ExecAsync(owner, DarlingManagedRoles.BuildEditMonitoredServerLegacyWrapperSql("config"), ct); + await InsertServerAsync(owner, 8701, Role, External, ct); + + /* The edit-only login the web host uses: EXECUTE on both signatures, and no right on the table itself. */ + login = await CreateLoginRoleAsync(owner, ct); + await ExecAsync(owner, $"GRANT EXECUTE ON FUNCTION config.edit_monitored_server({DarlingManagedRoles.EditMonitoredServerSignature}) TO {login}", ct); + await ExecAsync(owner, $"GRANT EXECUTE ON FUNCTION config.edit_monitored_server({DarlingManagedRoles.EditMonitoredServerLegacySignature}) TO {login}", ct); + await using var role = await OpenAsRoleAsync(scratch, login, ct); + + var before = await StoredAsync(owner, 8701, ct); + var nameBefore = await TextAsync(owner, "SELECT name || '|' || host || '|' || modified_at::text FROM config.config_monitored_servers WHERE server_id = 8701", ct); + + /* An empty name: the 17-argument function and the 15-argument wrapper answer with an outcome, and raise nothing. */ + var token = await TokenAsync(owner, 8701, ct); + Assert.Equal("invalid_value", await TextAsync(role, + $"SELECT outcome FROM config.edit_monitored_server(8701, '{token}'::timestamp, ARRAY['name']::text[], " + + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", ct)); + Assert.Equal("invalid_value", await TextAsync(role, + $"SELECT outcome FROM config.edit_monitored_server(8701, '{token}'::timestamp, ARRAY['name']::text[], " + + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL)", ct)); + + /* An empty host with a typed secret, on both. */ + Assert.Equal("invalid_value", await TextAsync(role, + $"SELECT outcome FROM config.edit_monitored_server(8701, '{token}'::timestamp, ARRAY['host','encrypted_password']::text[], " + + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'typed-secret', NULL, NULL, NULL, NULL, NULL, NULL)", ct)); + Assert.Equal("invalid_value", await TextAsync(role, + $"SELECT outcome FROM config.edit_monitored_server(8701, '{token}'::timestamp, ARRAY['host','encrypted_password']::text[], " + + "NULL, NULL, NULL, NULL, NULL, NULL, NULL, 'typed-secret', NULL, NULL, NULL, NULL)", ct)); + + Assert.Equal(before, await StoredAsync(owner, 8701, ct)); + Assert.Equal(nameBefore, await TextAsync(owner, "SELECT name || '|' || host || '|' || modified_at::text FROM config.config_monitored_servers WHERE server_id = 8701", ct)); + bodySucceeded = true; + } + finally + { + var loginToDrop = login; + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (cleanup, _) => DropLoginRoleAsync(cleanup, loginToDrop)); + } + } + + [Fact] + public async Task TheCheck_RefusesMarkupAndQuoteCharactersInARolePath() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + var id = 8710; + foreach (var path in new[] { "/x", "a/x", "a>b/x", "a\"b/x", "a'b/x", "a&b/x", "a`b/x", "a b/x" }) + { + var refused = await RefusalAsync(() => InsertServerAsync(owner, id++, $"arn:aws:iam::123456789012:role/{path}", null, ct)); + Assert.Equal("23514", refused); + } + + await InsertServerAsync(owner, id, "arn:aws:iam::123456789012:role/path_1+=,.@-/darling-monitor", null, ct); + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (_, _) => Task.CompletedTask); + } + } + + [Fact] + public async Task ARoleThatIsNotTheOwner_CannotPutANewRoleOverAStoredId_Pw004() + { + var baseConnectionString = BaseConnectionString(); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live AWS role tests (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + string? login = null; + var bodySucceeded = false; + try + { + await using var owner = await OpenOwnerAsync(scratch, ct); + await ExecAsync(owner, DarlingManagedRoles.BuildServerPasswordRulesSql("config"), ct); + await InsertServerAsync(owner, 8601, Role, External, ct); + await InsertServerAsync(owner, 8602, Role, null, ct); + + login = await CreateLoginRoleAsync(owner, ct); + await ExecAsync(owner, $"GRANT SELECT, UPDATE ON config.config_monitored_servers TO {login}", ct); + + /* An update here bumps the reload beacon, which a role that edits servers is granted (the managed batch grants it). */ + await ExecAsync(owner, "INSERT INTO config_service (id) VALUES (1) ON CONFLICT DO NOTHING", ct); + await ExecAsync(owner, $"GRANT SELECT, UPDATE ON config.config_service TO {login}", ct); + await using var role = await OpenAsRoleAsync(scratch, login, ct); + + /* A new role with the stored ID untouched: refused with PW004 and the sentence. */ + var refused = await Assert.ThrowsAsync(() => + ExecAsync(role, $"UPDATE config.config_monitored_servers SET aws_role_arn = {Lit(OtherRole)} WHERE server_id = 8601", ct)); + Assert.Equal("PW004", refused.SqlState); + Assert.Equal(AwsRoleSettings.RoleChangeNeedsExternalIdMessage, refused.MessageText); + Assert.Equal($"{Role} | {External} | true", await StoredAsync(owner, 8601, ct)); + + /* The ID sent with it, or cleared with it, or the same role again, or a server with no ID: all pass. */ + await ExecAsync(role, $"UPDATE config.config_monitored_servers SET aws_role_arn = {Lit(OtherRole)}, aws_external_id = {Lit(OtherExternal)} WHERE server_id = 8601", ct); + Assert.Equal($"{OtherRole} | {OtherExternal} | true", await StoredAsync(owner, 8601, ct)); + await ExecAsync(role, $"UPDATE config.config_monitored_servers SET aws_role_arn = {Lit(Role)}, aws_external_id = NULL WHERE server_id = 8601", ct); + Assert.Equal($"{Role} | | false", await StoredAsync(owner, 8601, ct)); + await ExecAsync(role, $"UPDATE config.config_monitored_servers SET aws_role_arn = {Lit(OtherRole)} WHERE server_id = 8602", ct); + Assert.Equal($"{OtherRole} | | false", await StoredAsync(owner, 8602, ct)); + await ExecAsync(role, "UPDATE config.config_monitored_servers SET name = 'renamed' WHERE server_id = 8602", ct); + + /* The owner is not held to it (the edit function answers external_id_needed first). */ + await InsertServerAsync(owner, 8603, Role, External, ct); + await ExecAsync(owner, $"UPDATE config.config_monitored_servers SET aws_role_arn = {Lit(OtherRole)} WHERE server_id = 8603", ct); + Assert.Equal($"{OtherRole} | {External} | true", await StoredAsync(owner, 8603, ct)); + + bodySucceeded = true; + } + finally + { + var loginToDrop = login; + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, (cleanup, _) => DropLoginRoleAsync(cleanup, loginToDrop)); + } + } +} diff --git a/Darling/Darling.Tests/AwsPerServerRoleRungTests.cs b/Darling/Darling.Tests/AwsPerServerRoleRungTests.cs new file mode 100644 index 0000000000..6a5ce94619 --- /dev/null +++ b/Darling/Darling.Tests/AwsPerServerRoleRungTests.cs @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Globalization; +using System.Linq; +using System.Reflection; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// V169: the per-server AWS role on config.config_monitored_servers (#5452). The static pins: the rung's place in +/// the ladder and its shape, the viewer's version probe, the column ACL, and the self-managed script's copy of the edit +/// function, its wrapper and the trigger rule. The store behaviour is in . +/// +/// This file carries the "I am the top rung" claims, which move to the next rung's tests when one lands above it. +/// +public sealed class AwsPerServerRoleRungTests +{ + internal const int RungVersion = 169; + private const int PreviousVersion = 168; + + /// This rung's sentinel ordinal in the viewer probe. No longer the last argument: V170's sentinel (#5495) follows it. + internal const int ProbeOrdinal = 144; + + private const string RoleColumn = "aws_role_arn"; + + [Fact] + public void TheRungIsRegisteredAtTheTopOfADenseLadder() + { + var versions = PgMigrations.Scripts.Select(s => s.Version).ToList(); + + Assert.Equal("aws-per-server-role", PgMigrations.Scripts.Single(s => s.Version == RungVersion).Name); + /* No longer the top rung: the PostgreSQL I/O hourly rollup rung (V170) landed above it. */ + Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version); + Assert.Equal(StorageVersion.SchemaVersion, versions.Max()); + Assert.True(RungVersion < StorageVersion.SchemaVersion); + Assert.Equal(versions.Distinct().OrderBy(v => v), versions); + Assert.Equal(PreviousVersion, RungVersion - 1); + Assert.Contains(PreviousVersion, versions); + } + + [Fact] + public void TheRungIsIdempotent_AndMovesNoData() + { + var rung = PgMigrations.Scripts.Single(s => s.Version == RungVersion).Sql; + + Assert.Equal(3, CountOf(rung, "ADD COLUMN IF NOT EXISTS")); + Assert.Equal(3, CountOf(rung, "ALTER TABLE config.config_monitored_servers ADD COLUMN")); + Assert.DoesNotContain("ALTER TABLE config_monitored_servers", rung, StringComparison.Ordinal); + Assert.Contains("aws_role_arn text;", rung, StringComparison.Ordinal); + Assert.Contains("aws_external_id text;", rung, StringComparison.Ordinal); + Assert.Contains("GENERATED ALWAYS AS (aws_external_id IS NOT NULL) STORED", rung, StringComparison.Ordinal); + + /* The check is added once, behind a catalog guard (the V62 pattern), so a re-run does not fail on it. */ + Assert.Equal(1, CountOf(rung, "ADD CONSTRAINT config_monitored_servers_aws_role_check")); + Assert.Contains("WHERE conname = 'config_monitored_servers_aws_role_check'", rung, StringComparison.Ordinal); + Assert.Contains("IF NOT EXISTS (SELECT 1 FROM pg_constraint", rung, StringComparison.Ordinal); + + foreach (var shape in new[] { "UPDATE ", "DELETE ", "INSERT ", "CREATE INDEX", "DROP " }) + { + Assert.DoesNotContain(shape, rung, StringComparison.Ordinal); + } + } + + [Fact] + public void TheCheckIsTheCoarseBackstop_WithNoRepeatCountOverTwoHundredFiftyFive() + { + var rung = PgMigrations.Scripts.Single(s => s.Version == RungVersion).Sql; + + /* No term of the check can be NULL, so an external ID with no role is false rather than unknown. */ + Assert.Contains("(aws_external_id IS NULL OR aws_role_arn IS NOT NULL)", rung, StringComparison.Ordinal); + Assert.Contains("aws_role_arn ~ '^arn:aws(-[a-z]+)*:iam::[0-9]{12}:role/[A-Za-z0-9_+=,.@/-]+$'", rung, StringComparison.Ordinal); + Assert.Contains("char_length(aws_role_arn) <= " + AwsRoleSettings.RoleArnMaxLength, rung, StringComparison.Ordinal); + Assert.Contains("aws_external_id ~ '^[A-Za-z0-9_+=,.@:/-]+$'", rung, StringComparison.Ordinal); + Assert.Contains( + $"char_length(aws_external_id) BETWEEN {AwsRoleSettings.ExternalIdMinLength} AND {AwsRoleSettings.ExternalIdMaxLength}", + rung, StringComparison.Ordinal); + + /* PostgreSQL's regular expressions refuse a repeat count above 255 (2201B at the first use), so the lengths are + char_length terms and the patterns carry no large count. */ + Assert.DoesNotMatch("[{][0-9]+,[0-9]*[}]", rung.Replace("{12}", string.Empty, StringComparison.Ordinal)); + } + + /* ---- the probe (three sites, top arm) ------------------------------------------------------------- */ + + [Fact] + public void TheProbeMapsAFullyMigratedStoreToThisTopRung() + { + Assert.Contains($"column_name = '{RoleColumn}'", ViewerDataService.StoreSchemaProbeSql, StringComparison.Ordinal); + Assert.Contains("table_name = 'config_monitored_servers'", ViewerDataService.StoreSchemaProbeSql, StringComparison.Ordinal); + + var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs"); + Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal); + Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 1})", viewer, StringComparison.Ordinal); + Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 2})", viewer, StringComparison.Ordinal); + Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 3})", viewer, StringComparison.Ordinal); + Assert.Contains($"reader.GetBoolean({ProbeOrdinal + 4})", viewer, StringComparison.Ordinal); + Assert.DoesNotContain($"reader.GetBoolean({ProbeOrdinal + 5})", viewer, StringComparison.Ordinal); + + Assert.Equal(StorageVersion.SchemaVersion, ViewerDataService.RequiredStoreSchemaVersion); + + var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", BindingFlags.NonPublic | BindingFlags.Static)!; + var arity = method.GetParameters().Length; + + /* A newer rung's sentinel (V173's, #5582) is the last argument. */ + Assert.Equal(ProbeOrdinal + 4, arity - 1); + + var all = Enumerable.Repeat((object)true, arity).ToArray(); + Assert.Equal(StorageVersion.SchemaVersion, (int)method.Invoke(null, all)!); + + var atThisRung = Enumerable.Range(0, arity).Select(i => (object)(i <= ProbeOrdinal)).ToArray(); + Assert.Equal(RungVersion, (int)method.Invoke(null, atThisRung)!); + var behind = (object[])atThisRung.Clone(); + behind[ProbeOrdinal] = false; + Assert.Equal(PreviousVersion, (int)method.Invoke(null, behind)!); + + /* In the source the arm sits ABOVE V168's, and returns this build's version. */ + var v169 = viewer.IndexOf("if (hasAwsRole)", StringComparison.Ordinal); + var v168 = viewer.IndexOf("if (hasPlanRegressionDaily)", StringComparison.Ordinal); + Assert.True(v169 >= 0, "the viewer has no V169 sentinel arm, so a fully-migrated store would map to 168"); + Assert.True(v168 >= 0, "the V168 arm is gone, so this pin is comparing against nothing"); + Assert.True(v169 < v168, "the V169 arm sits below V168's, so a current store maps one rung low"); + Assert.Contains( + "return " + RungVersion.ToString(CultureInfo.InvariantCulture) + ";", + viewer[v169..v168], StringComparison.Ordinal); + } + + /* ---- the column ACL -------------------------------------------------------------------------------- */ + + [Fact] + public void TheColumnAcl_ShowsTheRoleAndTheFlag_AndHidesTheExternalId() + { + var table = DarlingManagedRoles.ViewerRestrictedConfigTables.Single(t => t.Table == "config_monitored_servers"); + + Assert.Contains("aws_role_arn", table.NonSecretColumns); + Assert.Contains("aws_external_id_set", table.NonSecretColumns); + Assert.Contains("aws_external_id", table.SecretColumns); + Assert.DoesNotContain("aws_external_id", table.NonSecretColumns); + + /* The self-managed script grants the same two columns to both read-only roles, and never the ID. */ + var script = System.Text.RegularExpressions.Regex.Replace( + RepoFile.ReadRepoFile("Darling", "tools", "provision-roles.sql"), @"(?m)^\s*--.*$", string.Empty); + var grants = System.Text.RegularExpressions.Regex.Matches( + script, @"GRANT SELECT \([^;]*?\)\s+ON config\.config_monitored_servers TO (viewer|mcp);", + System.Text.RegularExpressions.RegexOptions.Singleline); + Assert.Equal(2, grants.Count); + foreach (System.Text.RegularExpressions.Match grant in grants) + { + var uncommented = grant.Value; + Assert.Contains("aws_role_arn", uncommented, StringComparison.Ordinal); + Assert.Contains("aws_external_id_set", uncommented, StringComparison.Ordinal); + Assert.DoesNotMatch(@"(?= 0 && wrapperAt > functionAt, "the wrapper must be created after the function it calls"); + Assert.Contains( + $"GRANT EXECUTE ON FUNCTION config.edit_monitored_server({DarlingManagedRoles.EditMonitoredServerSignature}) TO viewer, mcp;", + batch, StringComparison.Ordinal); + Assert.Contains( + $"GRANT EXECUTE ON FUNCTION config.edit_monitored_server({DarlingManagedRoles.EditMonitoredServerLegacySignature}) TO viewer, mcp;", + batch, StringComparison.Ordinal); + } + + [Fact] + public void TheSelfManagedScript_CarriesTheWrapper_BothGrants_AndTheTriggerRule() + { + var script = System.Text.RegularExpressions.Regex.Replace( + RepoFile.ReadRepoFile("Darling", "tools", "provision-roles.sql"), @"(?m)^\s*--.*$", string.Empty); + + Assert.Contains( + NormalizeSql(DarlingManagedRoles.BuildEditMonitoredServerLegacyWrapperSql("config")), NormalizeSql(script), StringComparison.Ordinal); + Assert.Contains( + $"GRANT EXECUTE ON FUNCTION config.edit_monitored_server({DarlingManagedRoles.EditMonitoredServerSignature}) TO viewer, mcp;", + script, StringComparison.Ordinal); + Assert.Contains( + $"GRANT EXECUTE ON FUNCTION config.edit_monitored_server({DarlingManagedRoles.EditMonitoredServerLegacySignature}) TO viewer, mcp;", + script, StringComparison.Ordinal); + + var rules = DarlingManagedRoles.BuildServerPasswordRulesSql("config"); + Assert.Contains("USING ERRCODE = 'PW004'", rules, StringComparison.Ordinal); + Assert.Contains(AwsRoleSettings.RoleChangeNeedsExternalIdMessage, rules, StringComparison.Ordinal); + Assert.Contains(NormalizeSql(rules), NormalizeSql(script), StringComparison.Ordinal); + } + + [Fact] + public void TheMcpLogin_UpdatesEveryColumnButTheRoleAndExternalId_InTheManagedBatchAndTheScript() + { + var columns = DarlingManagedRoles.McpMonitoredServerUpdateColumns.Split(',', StringSplitOptions.TrimEntries); + Assert.DoesNotContain("aws_role_arn", columns); + Assert.DoesNotContain("aws_external_id", columns); + Assert.DoesNotContain("aws_external_id_set", columns); + Assert.Equal(columns.Length, columns.Distinct(StringComparer.Ordinal).Count()); + + var batch = NormalizeSql(DarlingManagedRoles.BuildProvisioningSql( + ProvisioningTestSecrets.Admin, ProvisioningTestSecrets.Viewer, ProvisioningTestSecrets.Mcp)); + var script = NormalizeSql(RepoFile.ReadRepoFile("Darling", "tools", "provision-roles.sql")); + + Assert.Contains($"GRANT UPDATE ({DarlingManagedRoles.McpMonitoredServerUpdateColumns}) ON config.config_monitored_servers TO mcp;", script, StringComparison.Ordinal); + Assert.Contains("REVOKE UPDATE ON config.config_monitored_servers FROM mcp;", script, StringComparison.Ordinal); + Assert.Contains("GRANT INSERT, DELETE ON config.config_monitored_servers TO mcp;", script, StringComparison.Ordinal); + Assert.DoesNotContain("GRANT INSERT, UPDATE, DELETE ON config.config_monitored_servers", script, StringComparison.Ordinal); + + Assert.Contains($"GRANT UPDATE ({DarlingManagedRoles.McpMonitoredServerUpdateColumns}) ON", batch, StringComparison.Ordinal); + Assert.Contains("REVOKE UPDATE ON", batch, StringComparison.Ordinal); + Assert.Contains("GRANT INSERT, DELETE ON config.config_monitored_servers TO", batch, StringComparison.Ordinal); + Assert.DoesNotContain("GRANT INSERT, UPDATE, DELETE ON config.config_monitored_servers", batch, StringComparison.Ordinal); + } + + [Fact] + public void TheEditFunction_AnswersInvalidValue_ForAnEmptyRequiredField_AndForAValueTheTableRefuses() + { + foreach (var body in new[] + { + NormalizeSql(DarlingManagedRoles.BuildEditMonitoredServerFunctionSql("config")), + NormalizeSql(RepoFile.ReadRepoFile("Darling", "tools", "provision-roles.sql")), + }) + { + Assert.Contains("'name' = ANY (p_columns) AND p_name IS NULL", body, StringComparison.Ordinal); + Assert.Contains("OR v_host IS NULL OR v_port IS NULL OR v_auth IS NULL OR v_encrypt_mode IS NULL", body, StringComparison.Ordinal); + Assert.Contains("'invalid_value'::text", body, StringComparison.Ordinal); + Assert.Contains("EXCEPTION WHEN integrity_constraint_violation THEN", body, StringComparison.Ordinal); + } + } + + private static string NormalizeSql(string sql) => + System.Text.RegularExpressions.Regex.Replace( + System.Text.RegularExpressions.Regex.Replace(sql, @"(?m)^\s*--.*$", ""), @"\s+", " ").Trim(); + + private static int CountOf(string haystack, string needle) + { + var count = 0; + for (var at = haystack.IndexOf(needle, StringComparison.Ordinal); + at >= 0; + at = haystack.IndexOf(needle, at + needle.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } +} diff --git a/Darling/Darling.Tests/AwsRoleAllowlistTests.cs b/Darling/Darling.Tests/AwsRoleAllowlistTests.cs new file mode 100644 index 0000000000..716c1de8c7 --- /dev/null +++ b/Darling/Darling.Tests/AwsRoleAllowlistTests.cs @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Targets; +using Xunit; + +namespace Darling.Tests; + +/// +/// Which AWS roles the web and MCP may set (#5452): the list type, the allowedAwsRoles darling.json setting, +/// and where the worker sets the list in use. +/// +public sealed class AwsRoleAllowlistTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + private const string PathRole = "arn:aws:iam::123456789012:role/team/darling-monitor"; + private const string OtherAccountRole = "arn:aws:iam::210987654321:role/darling-monitor"; + + [Fact] + public void Empty_AllowsNothing() + { + Assert.False(AwsRoleAllowlist.Empty.IsAllowed(Role)); + Assert.False(AwsRoleAllowlist.Empty.IsAllowed(null)); + Assert.Equal(0, AwsRoleAllowlist.Empty.Count); + } + + [Fact] + public void Current_StartsEmpty() + { + // Nothing in the test run sets it: a test names its list through the overloads. + Assert.Same(AwsRoleAllowlist.Empty, AwsRoleAllowlist.Current); + } + + [Fact] + public void AnArnEntry_AllowsExactlyThatArn_CaseSensitive() + { + var list = AwsRoleAllowlist.From(new[] { Role }); + + Assert.True(list.IsAllowed(Role)); + Assert.True(AwsRoleAllowlist.IsAllowed(Role, list)); + Assert.False(list.IsAllowed(Role.ToUpperInvariant())); + Assert.False(list.IsAllowed("arn:aws:iam::123456789012:role/Darling-Monitor")); + Assert.False(list.IsAllowed(PathRole)); + Assert.False(list.IsAllowed(OtherAccountRole)); + } + + [Fact] + public void AnAccountIdEntry_AllowsEveryRoleInThatAccount_AndNoOther() + { + var list = AwsRoleAllowlist.From(new[] { "123456789012" }); + + Assert.True(list.IsAllowed(Role)); + Assert.True(list.IsAllowed(PathRole)); + Assert.False(list.IsAllowed(OtherAccountRole)); + } + + [Fact] + public void ABareAccountIdEntry_AllowsThatAccountInTheAwsPartitionOnly() + { + var list = AwsRoleAllowlist.From(new[] { "123456789012" }); + + Assert.True(list.IsAllowed(Role)); + Assert.False(list.IsAllowed("arn:aws-cn:iam::123456789012:role/darling-monitor")); + Assert.False(list.IsAllowed("arn:aws-us-gov:iam::123456789012:role/darling-monitor")); + } + + [Fact] + public void APartitionQualifiedAccountEntry_AllowsThatAccountInThatPartitionOnly() + { + var china = AwsRoleAllowlist.From(new[] { "aws-cn:123456789012" }); + Assert.True(china.IsAllowed("arn:aws-cn:iam::123456789012:role/darling-monitor")); + Assert.True(china.IsAllowed("arn:aws-cn:iam::123456789012:role/team/darling-monitor")); + Assert.False(china.IsAllowed(Role)); + Assert.False(china.IsAllowed("arn:aws-us-gov:iam::123456789012:role/darling-monitor")); + Assert.False(china.IsAllowed("arn:aws-cn:iam::210987654321:role/darling-monitor")); + + var gov = AwsRoleAllowlist.From(new[] { "aws-us-gov:123456789012" }); + Assert.True(gov.IsAllowed("arn:aws-us-gov:iam::123456789012:role/darling-monitor")); + Assert.False(gov.IsAllowed(Role)); + Assert.False(gov.IsAllowed("arn:aws-cn:iam::123456789012:role/darling-monitor")); + } + + [Fact] + public void TheAwsPartitionCanBeWrittenOut_AndMatchesTheBareForm() + { + var list = AwsRoleAllowlist.From(new[] { "aws:123456789012" }); + Assert.True(list.IsAllowed(Role)); + Assert.False(list.IsAllowed("arn:aws-cn:iam::123456789012:role/darling-monitor")); + } + + [Fact] + public void SeveralPartitionEntries_EachAllowTheirOwnPartition() + { + var list = AwsRoleAllowlist.From(new[] { "123456789012", "aws-cn:123456789012", "aws-us-gov:210987654321" }); + Assert.Equal(3, list.Count); + Assert.True(list.IsAllowed(Role)); + Assert.True(list.IsAllowed("arn:aws-cn:iam::123456789012:role/darling-monitor")); + Assert.False(list.IsAllowed("arn:aws-us-gov:iam::123456789012:role/darling-monitor")); + Assert.True(list.IsAllowed("arn:aws-us-gov:iam::210987654321:role/darling-monitor")); + } + + [Fact] + public void AnAccountIdEntry_DoesNotMatchAnAccountIdInsideAnotherPartOfTheArn() + { + var list = AwsRoleAllowlist.From(new[] { "123456789012" }); + + // The account id appears in the role name and path, not in the account field. + Assert.False(list.IsAllowed("arn:aws:iam::210987654321:role/123456789012")); + Assert.False(list.IsAllowed("arn:aws:iam::210987654321:role/123456789012/darling-monitor")); + // Not a role ARN at all. + Assert.False(list.IsAllowed("arn:aws:iam::123456789012:user/darling-monitor")); + Assert.False(list.IsAllowed("123456789012")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ABlankRole_IsNeverAllowed(string? role) + { + var list = AwsRoleAllowlist.From(new[] { Role, "123456789012" }); + + Assert.False(list.IsAllowed(role)); + } + + [Fact] + public void TheRoleAndEntriesAreTrimmed() + { + var list = AwsRoleAllowlist.From(new[] { " " + Role + " ", " 123456789012 " }); + + Assert.True(list.IsAllowed(" " + Role)); + Assert.True(list.IsAllowed(OtherAccountRole.Replace("210987654321", "123456789012"))); + Assert.Equal(2, list.Count); + } + + [Fact] + public void From_DropsBlankAndUnusableEntries_AndCountsARepeatOnce() + { + var list = AwsRoleAllowlist.From(new string?[] { null, "", " ", "not-an-arn", "12345", "1234567890123", Role, Role }); + + Assert.Equal(1, list.Count); + Assert.True(list.IsAllowed(Role)); + Assert.Same(AwsRoleAllowlist.Empty, AwsRoleAllowlist.From(new string?[] { null, "junk" })); + Assert.Same(AwsRoleAllowlist.Empty, AwsRoleAllowlist.From(null)); + } + + [Theory] + [InlineData(Role, true)] + [InlineData(PathRole, true)] + [InlineData("123456789012", true)] + [InlineData(" 123456789012 ", true)] + [InlineData("aws-cn:123456789012", true)] + [InlineData("aws-us-gov:123456789012", true)] + [InlineData(" aws-cn:123456789012 ", true)] + [InlineData("aws-cn:12345678901", false)] + [InlineData("aws-cn:", false)] + [InlineData(":123456789012", false)] + [InlineData("china:123456789012", false)] + [InlineData("aws-cn:aws-cn:123456789012", false)] + [InlineData("12345678901", false)] + [InlineData("1234567890123", false)] + [InlineData("12345678901a", false)] + [InlineData("*", false)] + [InlineData("", false)] + [InlineData("arn:aws:iam::123456789012:user/x", false)] + [InlineData("arn:aws:iam::123456789012:role/", false)] + public void ValidateEntry_AcceptsAnArnOrATwelveDigitId(string entry, bool ok) + { + Assert.Equal(ok, AwsRoleAllowlist.ValidateEntry(entry) is null); + } + + [Fact] + public void ValidateEntry_AnswersWithTheFixedSentence() + { + Assert.Equal(AwsRoleAllowlist.InvalidEntryMessage, AwsRoleAllowlist.ValidateEntry("junk")); + Assert.Equal(AwsRoleAllowlist.InvalidEntryMessage, AwsRoleAllowlist.ValidateEntry(null)); + } + + [Fact] + public void FromConfig_IsTheSettingPlusEveryServersRole() + { + var config = new DarlingConfig(); + config.AllowedAwsRoles.Add(Role); + config.AllowedAwsRoles.Add("210987654321"); + config.Servers.Add(new MonitoredServer { Name = "a", AwsRoleArn = "arn:aws:iam::111111111111:role/from-server" }); + config.Servers.Add(new MonitoredServer { Name = "b" }); + + var list = AwsRoleAllowlist.FromConfig(config); + + Assert.True(list.IsAllowed(Role)); + Assert.True(list.IsAllowed(OtherAccountRole)); + Assert.True(list.IsAllowed("arn:aws:iam::111111111111:role/from-server")); + Assert.False(list.IsAllowed("arn:aws:iam::111111111111:role/other")); + Assert.Equal(3, list.Count); + } + + [Fact] + public void FromConfig_WithNothingSet_AllowsNothing() + { + Assert.Same(AwsRoleAllowlist.Empty, AwsRoleAllowlist.FromConfig(new DarlingConfig())); + } + + [Fact] + public void TheSettingReadsFromDarlingJson_UnderItsCamelCaseName() + { + var config = DarlingConfig.Parse(""" + { "allowedAwsRoles": [ "arn:aws:iam::123456789012:role/darling-monitor", "210987654321" ] } + """); + + Assert.Equal(new[] { Role, "210987654321" }, config.AllowedAwsRoles); + } + + [Fact] + public void WhenTheSettingIsOmitted_TheListIsEmpty_AndTheSampleValidatesClean() + { + Assert.Empty(new DarlingConfig().AllowedAwsRoles); + + var sample = DarlingConfig.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "darling.sample.json"))); + Assert.Empty(sample.AllowedAwsRoles); + Assert.Empty(sample.Validate()); + } + + [Fact] + public void Validate_RejectsAnEntryThatIsNeitherAnArnNorATwelveDigitId_NamingItsPosition() + { + var config = new DarlingConfig(); + config.Postgres.ConnectionString = "Host=localhost;Database=darling;Username=x;Password=y"; + config.Servers.Add(new MonitoredServer { Name = "sql1", Host = "sql1.example", Auth = "integrated" }); + config.AllowedAwsRoles.Add(Role); + config.AllowedAwsRoles.Add("123456789012"); + config.AllowedAwsRoles.Add("aws-cn:123456789012"); + Assert.Empty(config.Validate()); + + config.AllowedAwsRoles.Add("not-a-role"); + config.AllowedAwsRoles.Add(""); + var problems = config.Validate(); + + Assert.Equal(2, problems.Count); + Assert.Contains(problems, p => p.StartsWith("allowedAwsRoles[3]: ", StringComparison.Ordinal) + && p.EndsWith(AwsRoleAllowlist.InvalidEntryMessage, StringComparison.Ordinal)); + Assert.Contains(problems, p => p.StartsWith("allowedAwsRoles[4]: ", StringComparison.Ordinal)); + } + + [Fact] + public void TheWorkerSetsTheListInUseOnce_BeforeItBuildsTheRunner() + { + var dir = AppContext.BaseDirectory; + string? worker = null; + while (dir is not null) + { + var candidate = Path.Combine(dir, "PerformanceMonitor.Darling.Service", "DarlingWorker.cs"); + if (File.Exists(candidate)) + { + worker = candidate; + break; + } + + dir = Path.GetDirectoryName(dir); + } + + if (worker is null) + { + // The test host runs away from the source tree (a published layout): nothing to pin. + return; + } + + var text = File.ReadAllText(worker); + var sets = Regex.Matches(text, @"AwsRoleAllowlist\.Current\s*=").Count; + Assert.Equal(1, sets); + Assert.True( + text.IndexOf("AwsRoleAllowlist.Current =", StringComparison.Ordinal) + < text.IndexOf("new DarlingCollectorRunner(", StringComparison.Ordinal), + "The list is set before the runner is built."); + } + + [Fact] + public void NothingOutsideTheWorkerSetsTheListInUse() + { + var dir = AppContext.BaseDirectory; + string? service = null; + while (dir is not null) + { + var candidate = Path.Combine(dir, "PerformanceMonitor.Darling.Service"); + if (File.Exists(Path.Combine(candidate, "DarlingWorker.cs"))) + { + service = candidate; + break; + } + + dir = Path.GetDirectoryName(dir); + } + + if (service is null) + { + return; + } + + var setters = Directory.EnumerateFiles(service, "*.cs", SearchOption.AllDirectories) + .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + && !f.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + .Where(f => Regex.IsMatch(File.ReadAllText(f), @"AwsRoleAllowlist\.Current\s*=")) + .Select(Path.GetFileName) + .ToArray(); + + Assert.Equal(new[] { "DarlingWorker.cs" }, setters); + } +} diff --git a/Darling/Darling.Tests/AwsRoleBundleEndToEndLiveTests.cs b/Darling/Darling.Tests/AwsRoleBundleEndToEndLiveTests.cs new file mode 100644 index 0000000000..42c2b5e9f4 --- /dev/null +++ b/Darling/Darling.Tests/AwsRoleBundleEndToEndLiveTests.cs @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Targets; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/* #1776 own-store: the test mints its own scratch database through ScratchPostgres. */ + +/// +/// #5452: each server can use its own AWS role, end to end through the diagnostics bundle. The worker's role arm writes a +/// PERMISSIONS row to collection_log for a server that has a role and an external ID; a bundle is then built from that +/// store (the server is known only to the store registry); and the bundle names the role as an alias and never holds the +/// external ID. Names are synthetic (the example account 123456789012). +/// +public sealed class AwsRoleBundleEndToEndLiveTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + private const string ExternalId = "ext-7Hq2mZ9vLx"; + private const string ServerName = "alpha-pg-01"; + private const string Host = "alpha-pg-01.abcdefghijkl.us-east-1.rds.amazonaws.com"; + + private static string? BaseConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + /* The server as the store holds it: the registry row (with its role and external ID) and the collected-data server row. */ + private static async Task SeedServerAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) + { + await using var insert = new NpgsqlCommand( + "INSERT INTO config.config_monitored_servers (server_id, name, host, database, auth, username, engine, port, aws_role_arn, aws_external_id) " + + "VALUES (9201, $1, $2, 'postgres', 'sql', 'pgcollect', 'postgres', 5432, $3, $4)", connection); + insert.Parameters.AddWithValue(ServerName); + insert.Parameters.AddWithValue(Host); + insert.Parameters.AddWithValue(Role); + insert.Parameters.AddWithValue(ExternalId); + await insert.ExecuteNonQueryAsync(ct); + + await using var registered = new NpgsqlCommand( + "INSERT INTO collect.servers (server_id, server_name, display_name, is_enabled, created_date, modified_date) " + + "VALUES (9201, $1, $1, true, now(), now())", connection); + registered.Parameters.AddWithValue(ServerName); + await registered.ExecuteNonQueryAsync(ct); + } + + [Fact] + public async Task ABundleBuiltFromARoleServersStore_NamesTheRoleAsAnAlias_AndNeverHoldsTheExternalId() + { + Assert.SkipWhen(string.IsNullOrEmpty(BaseConnectionString), "Set DARLING_TEST_PG to run the live bundle test."); + var ct = TestContext.Current.CancellationToken; + var scratch = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct); + var root = Directory.CreateTempSubdirectory("darling-bundle-awsrole-"); + var bodySucceeded = false; + try + { + await using (var connection = new NpgsqlConnection(scratch.ConnectionString)) + { + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await SeedServerAsync(connection, ct); + } + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + + // The role arm's input: an STS refusal whose own text repeats the external ID, read through the real RDS reader. + var sts = new FakeSts((_, _) => throw FakeSts.Error("AccessDenied", $"not authorized (ExternalId={ExternalId})")); + var cache = new AwsRoleCredentialCache(AwsRoleAllowlist.From([Role]), null, sts.Factory, FakeSts.Source, new ManualTimeProvider(), null, new CapturingAwsLogger()); + var ingestor = new RdsCpuIngestor( + NpgsqlDataSource.Create("Host=localhost;Port=1;Database=unused"), + verifier: RdsEndpointVerifier.ForTests(enforce: true, loginProbe: (_, _) => Task.FromResult(null)), + roles: cache); + var thrown = await Record.ExceptionAsync( + () => ingestor.IngestAsync(9201, ServerName, Host, "Host=" + Host, new AwsRoleKey(Role, ExternalId), ct)); + var fault = DarlingWorker.AwsRoleConfigurationFault(thrown!); + Assert.NotNull(fault); + + // The worker's role arm: its note, written to collection_log as PERMISSIONS in the storage-time slot. + var runtime = new ServerRuntime + { + Config = new MonitoredServer { Name = ServerName, Host = Host }, + ConnectionString = "Host=" + Host, + Target = new CollectorTargetInfo { Engine = CollectorTargetEngine.PostgreSql }, + StorageName = ServerName, + ServerId = 9201, + }; + var note = DarlingWorker.AwsRoleFaultNote(new CapturingAwsLogger(), ServerName, "pg_plan_capture", fault!); + await DarlingObservability.LogCollectionAsync( + postgres, runtime, "pg_plan_capture", "PERMISSIONS", 0, 0, 5, note, + fanout: null, phases: null, drain: null, fetchPhases: null, sweepPeerMaxMs: null, null, ct); + + // The store really holds the role in that row, so the bundle's alias is doing the work. + await using (var verify = await postgres.OpenConnectionAsync(ct)) + await using (var read = new NpgsqlCommand( + "SELECT error_message FROM collection_log WHERE server_id = 9201 AND status = 'PERMISSIONS'", verify)) + { + var stored = (string?)await read.ExecuteScalarAsync(ct); + Assert.Contains(Role, stored, StringComparison.Ordinal); + Assert.DoesNotContain(ExternalId, stored, StringComparison.Ordinal); + } + + var options = DiagnosticsBundle.ParseArgs(new[] { Path.Combine(root.FullName, "b.json"), "--log-dir", root.FullName }).Options!; + var outcome = await DiagnosticsBundleRunner.BuildAsync(options, new DarlingConfig(), scratch.ConnectionString, postgres, null, null, ct); + + Assert.NotEqual(DarlingCliCommands.DiagnosticsBundleExitCode.LeakGuard, outcome.ExitCode); + Assert.NotNull(outcome.Text); + Assert.DoesNotContain(ExternalId, outcome.Text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Role, outcome.Text, StringComparison.Ordinal); + Assert.DoesNotContain("darling-monitor", outcome.Text, StringComparison.Ordinal); + Assert.DoesNotContain("123456789012", outcome.Text, StringComparison.Ordinal); + Assert.DoesNotContain(ServerName, outcome.Text, StringComparison.Ordinal); + + // The bundle still says what happened: the collector's PERMISSIONS answer, with the role under its alias. + Assert.Contains("PERMISSIONS", outcome.Text, StringComparison.Ordinal); + Assert.Contains("role-", outcome.Text, StringComparison.Ordinal); + Assert.Contains("trust policy", outcome.Text, StringComparison.Ordinal); + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => await Task.CompletedTask); + await scratch.DisposeAsync(); + root.Delete(recursive: true); + } + } + + /* A second row that carries the external ID as plain text, the way an unscrubbed message from AWS could: the bundle + removes it as well, so no text the store holds can carry the ID out. */ + [Fact] + public async Task AnExternalIdTheStoreHoldsInAnyRowText_IsRemovedFromTheBundle() + { + Assert.SkipWhen(string.IsNullOrEmpty(BaseConnectionString), "Set DARLING_TEST_PG to run the live bundle test."); + var ct = TestContext.Current.CancellationToken; + var scratch = await ScratchPostgres.CreateAsync(BaseConnectionString!, ct); + var root = Directory.CreateTempSubdirectory("darling-bundle-awsrole-"); + var bodySucceeded = false; + try + { + await using (var connection = new NpgsqlConnection(scratch.ConnectionString)) + { + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await SeedServerAsync(connection, ct); + } + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var runtime = new ServerRuntime + { + Config = new MonitoredServer { Name = ServerName, Host = Host }, + ConnectionString = "Host=" + Host, + Target = new CollectorTargetInfo { Engine = CollectorTargetEngine.PostgreSql }, + StorageName = ServerName, + ServerId = 9201, + }; + await DarlingObservability.LogCollectionAsync( + postgres, runtime, "pg_plan_capture", "ERROR", 0, 0, 5, $"AWS answered for role {Role}: request externalId={ExternalId}", + fanout: null, phases: null, drain: null, fetchPhases: null, sweepPeerMaxMs: null, null, ct); + + var options = DiagnosticsBundle.ParseArgs(new[] { Path.Combine(root.FullName, "b.json"), "--log-dir", root.FullName }).Options!; + var outcome = await DiagnosticsBundleRunner.BuildAsync(options, new DarlingConfig(), scratch.ConnectionString, postgres, null, null, ct); + + Assert.NotEqual(DarlingCliCommands.DiagnosticsBundleExitCode.LeakGuard, outcome.ExitCode); + Assert.NotNull(outcome.Text); + Assert.DoesNotContain(ExternalId, outcome.Text, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(Role, outcome.Text, StringComparison.Ordinal); + Assert.DoesNotContain("darling-monitor", outcome.Text, StringComparison.Ordinal); + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => await Task.CompletedTask); + await scratch.DisposeAsync(); + root.Delete(recursive: true); + } + } +} diff --git a/Darling/Darling.Tests/AwsRoleCredentialCacheTests.cs b/Darling/Darling.Tests/AwsRoleCredentialCacheTests.cs new file mode 100644 index 0000000000..3e5c0eccc4 --- /dev/null +++ b/Darling/Darling.Tests/AwsRoleCredentialCacheTests.cs @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Threading.Tasks; +using PerformanceMonitor.Darling.Service.Targets; +using Xunit; + +namespace Darling.Tests; + +/// +/// The per-process cache of assumed-role credentials (#5452): the allow list and the partition are checked on every +/// call and before any AWS call, and one key means one entry. The example account is 123456789012. +/// +public sealed class AwsRoleCredentialCacheTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + private const string ChinaRole = "arn:aws-cn:iam::123456789012:role/darling-monitor"; + private const string GovRole = "arn:aws-us-gov:iam::123456789012:role/darling-monitor"; + + private static AwsRoleCredentialCache Cache(FakeSts sts, AwsRoleAllowlist? list = null, ManualTimeProvider? clock = null) => + new(list ?? AwsRoleAllowlist.From(new[] { "123456789012", "aws-cn:123456789012", "aws-us-gov:123456789012" }), null, sts.Factory, FakeSts.Source, clock ?? new ManualTimeProvider()); + + /* ---- the allow list, at use ---------------------------------------------------------------------------- */ + + [Fact] + public void ARoleTheListDoesNotAllow_IsNeverAssumed_WithTheSettledText() + { + var sts = new FakeSts(); + var cache = Cache(sts, AwsRoleAllowlist.Empty); + + var ex = Assert.Throws(() => cache.For(new AwsRoleKey(Role, null), "us-east-1")); + + Assert.Equal(AwsRoleAssumeKind.NotAllowed, ex.Kind); + Assert.True(ex.IsConfiguration); + Assert.Equal( + "The AWS role arn:aws:iam::123456789012:role/darling-monitor on this server is not in allowedAwsRoles in darling.json, so it was not used. " + + "Nothing was read this cycle. Add the role or its account id to the list and restart the service, or clear the role on this server.", + ex.Message); + Assert.Equal(0, cache.Count); + Assert.Equal(0, sts.Calls); + Assert.Empty(sts.Regions); + } + + [Fact] + public void ARoleOnAnotherAccountsList_IsNotAllowed() + { + var cache = Cache(new FakeSts(), AwsRoleAllowlist.From(new[] { "999999999999", "arn:aws:iam::123456789012:role/other-role" })); + + var ex = Assert.Throws(() => cache.For(new AwsRoleKey(Role, null), "us-east-1")); + + Assert.Equal(AwsRoleAssumeKind.NotAllowed, ex.Kind); + } + + [Theory] + [InlineData("123456789012")] + [InlineData("arn:aws:iam::123456789012:role/darling-monitor")] + public void AnAccountIdOrTheExactArn_AllowsTheRole(string entry) + { + var cache = Cache(new FakeSts(), AwsRoleAllowlist.From(new[] { entry })); + + Assert.NotNull(cache.For(new AwsRoleKey(Role, null), "us-east-1")); + Assert.True(cache.Contains(new AwsRoleKey(Role, null))); + } + + [Fact] + public async Task TheListIsCheckedOnEveryCall_EvenWhenTheRoleIsAlreadyCached() + { + var sts = new FakeSts(); + var cache = Cache(sts); + var key = new AwsRoleKey(Role, null); + + var credentials = cache.For(key, "us-east-1"); + await credentials.GetCredentialsAsync(); + Assert.Equal(1, sts.Calls); + + var ex = Assert.Throws(() => cache.For(key, "us-east-1", AwsRoleAllowlist.Empty)); + Assert.Equal(AwsRoleAssumeKind.NotAllowed, ex.Kind); + Assert.Equal(1, sts.Calls); + + /* The cache's own list still allows it, so the next call without an override is served. */ + Assert.NotNull(cache.For(key, "us-east-1")); + } + + [Fact] + public void TheListIsCheckedBeforeThePartition() + { + var cache = Cache(new FakeSts(), AwsRoleAllowlist.Empty); + + var ex = Assert.Throws(() => cache.For(new AwsRoleKey(Role, null), "cn-north-1")); + + Assert.Equal(AwsRoleAssumeKind.NotAllowed, ex.Kind); + } + + /* ---- the partition, on every call ---------------------------------------------------------------------- */ + + [Fact] + public async Task TheRolePartitionMustBeTheRegionsPartition_OnEveryCall() + { + var sts = new FakeSts(); + var cache = Cache(sts); + var key = new AwsRoleKey(Role, null); + + await cache.For(key, "us-east-1").GetCredentialsAsync(); + + var china = Assert.Throws(() => cache.For(key, "cn-north-1")); + Assert.Equal(AwsRoleAssumeKind.PartitionMismatch, china.Kind); + Assert.True(china.IsConfiguration); + Assert.Equal( + "Role arn:aws:iam::123456789012:role/darling-monitor is in AWS partition aws, but this target's region cn-north-1 is in partition aws-cn. " + + "A role can be assumed only inside its own partition. Nothing was read this cycle. " + + "Set a role from the target's partition on this server, or clear the role, or correct the server's host name if it names the wrong region.", + china.Message); + + /* The entry exists and was used from us-east-1, and the mismatch is still refused: the check is per call. */ + var gov = Assert.Throws(() => cache.For(key, "us-gov-west-1")); + Assert.Equal(AwsRoleAssumeKind.PartitionMismatch, gov.Kind); + Assert.NotNull(cache.For(key, "eu-west-1")); + Assert.Equal(1, sts.Calls); + } + + [Theory] + [InlineData(ChinaRole, "cn-north-1")] + [InlineData(GovRole, "us-gov-west-1")] + [InlineData(Role, "ap-southeast-2")] + public void ARoleInTheRegionsOwnPartition_IsServed(string role, string region) + { + var cache = Cache(new FakeSts()); + + Assert.NotNull(cache.For(new AwsRoleKey(role, null), region)); + } + + [Fact] + public void AnArnWithNoPartition_IsAMismatch_NotACrash() + { + var cache = Cache(new FakeSts(), AwsRoleAllowlist.From(new[] { "not-an-arn" })); + + Assert.Equal("", AwsRoleCredentialCache.PartitionOf("not-an-arn")); + Assert.Equal("aws-cn", AwsRoleCredentialCache.PartitionOf(ChinaRole)); + Assert.Throws(() => cache.For(new AwsRoleKey(Role, null), " ")); + } + + /* ---- one entry per key --------------------------------------------------------------------------------- */ + + [Fact] + public async Task OneKey_OneEntry_OneStsCall_ForEveryServerThatNamesIt() + { + var sts = new FakeSts(); + var cache = Cache(sts); + var key = new AwsRoleKey(Role, "shared-external-id"); + + var first = cache.For(key, "us-east-1"); + var second = cache.For(new AwsRoleKey(Role, "shared-external-id"), "eu-west-1"); + await Task.WhenAll(first.GetCredentialsAsync(), second.GetCredentialsAsync()); + + Assert.Equal(1, cache.Count); + Assert.Equal(1, sts.Calls); + } + + [Fact] + public void ARoleWithAnotherExternalId_IsAnotherEntry() + { + var cache = Cache(new FakeSts()); + + cache.For(new AwsRoleKey(Role, "first-external-id"), "us-east-1"); + cache.For(new AwsRoleKey(Role, "second-external-id"), "us-east-1"); + cache.For(new AwsRoleKey(Role, null), "us-east-1"); + + Assert.Equal(3, cache.Count); + } + + [Fact] + public void Retain_DropsTheKeysNoEnabledServerNamesAnyMore() + { + var cache = Cache(new FakeSts()); + var keep = new AwsRoleKey(Role, "keep-external-id"); + var drop = new AwsRoleKey(Role, "drop-external-id"); + cache.For(keep, "us-east-1"); + cache.For(drop, "us-east-1"); + + cache.Retain(new[] { keep }); + + Assert.True(cache.Contains(keep)); + Assert.False(cache.Contains(drop)); + Assert.Equal(1, cache.Count); + + cache.Retain(Array.Empty()); + Assert.Equal(0, cache.Count); + } +} diff --git a/Darling/Darling.Tests/AwsRoleEditTests.cs b/Darling/Darling.Tests/AwsRoleEditTests.cs new file mode 100644 index 0000000000..5442ff252d --- /dev/null +++ b/Darling/Darling.Tests/AwsRoleEditTests.cs @@ -0,0 +1,468 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Targets; +using PerformanceMonitor.Darling.Storage; +using Xunit; +using Edit = PerformanceMonitor.Darling.Service.Mcp.DarlingMcpServerAdminTools; + +namespace Darling.Tests; + +/// +/// #5452: which AWS roles the web and MCP may set. The edit's plan and the add's parse take the list as an argument +/// (these tests never set , a process-wide value): no list, a role not on it, a +/// listed role, a 12-digit account entry, a blank role that keeps the stored one, an explicit null that removes it, a +/// role change that would keep the stored external ID unseen, and the typed external ID kept out of every answer and +/// audit line. The role below is the example account's. +/// +public sealed class AwsRoleEditTests +{ + private const string RoleA = "arn:aws:iam::123456789012:role/darling-monitor"; + private const string RoleB = "arn:aws:iam::123456789012:role/darling-other"; + private const string OtherAccountRole = "arn:aws:iam::210987654321:role/darling-monitor"; + private const string TypedId = "ext-id-7f3a9c"; + + private static readonly DateTime Stamp = new(2026, 10, 5, 12, 0, 0, 123, DateTimeKind.Unspecified); + + private static Edit.ServerEditRow PgRow(string? role = null, bool idSet = false) => new( + ServerId: 41, Name: "pg-01", Host: "pg-01.example.test", Port: 5432, Database: null, ReadOnlyIntent: false, Engine: "postgres", + Auth: "sql", Username: "monitor", EncryptMode: "Mandatory", TrustServerCertificate: false, + MultiSubnetFailover: false, MonthlyCostUsd: 10m, ModifiedAt: Stamp.AddTicks(7), AwsRoleArn: role, AwsExternalIdSet: idSet); + + private static Edit.ServerEditRow SqlServerRow() => new( + ServerId: 42, Name: "sql-01", Host: "sql-01.example.test", Port: 0, Database: null, ReadOnlyIntent: false, Engine: "sqlserver", + Auth: "sql", Username: "monitor", EncryptMode: "Mandatory", TrustServerCertificate: false, + MultiSubnetFailover: false, MonthlyCostUsd: 10m, ModifiedAt: Stamp.AddTicks(7)); + + private static (Edit.ServerEditPlan? Plan, string? Error) Plan(Edit.ServerEditRow row, string json, AwsRoleAllowlist list) + { + var (changes, error) = Edit.ParseEditChanges(json); + Assert.Null(error); + return Edit.PlanEdit(row, changes!, TestKeyRings.Healthy.Status, list); + } + + [Fact] + public void SettingARole_WithNoList_IsRefusedWithTheNoListSentence() + { + var (plan, error) = Plan(PgRow(), $"{{\"aws_role_arn\":\"{RoleA}\"}}", AwsRoleAllowlist.Empty); + Assert.Null(plan); + Assert.Equal( + "The web and MCP cannot set an AWS role until darling.json lists the roles they may use. Add allowedAwsRoles to darling.json, then restart the service.", + error); + } + + [Fact] + public void SettingARole_NotOnTheList_IsRefusedWithTheNotListedSentence() + { + var (plan, error) = Plan(PgRow(), $"{{\"aws_role_arn\":\"{OtherAccountRole}\"}}", AwsRoleAllowlist.From([RoleA])); + Assert.Null(plan); + Assert.Equal( + "That AWS role is not on the list of roles the web and MCP may set. Add it to allowedAwsRoles in darling.json and restart the service.", + error); + } + + [Fact] + public void SettingARole_OnTheList_ByArnOrByAccountId_IsPlanned() + { + foreach (var entry in new[] { RoleA, "123456789012" }) + { + var (plan, error) = Plan(PgRow(), $"{{\"aws_role_arn\":\" {RoleA} \"}}", AwsRoleAllowlist.From([entry])); + Assert.Null(error); + var set = Assert.Single(plan!.Sets, s => s.Field == "aws_role_arn"); + Assert.Equal("aws_role_arn", set.Column); + Assert.Equal(RoleA, set.Value); + Assert.Contains("aws_role_arn", plan.ChangedFields); + } + } + + [Fact] + public void ABlankOrAbsentRole_KeepsTheStoredRole_AndIsNeverChecked() + { + foreach (var json in new[] { "{\"aws_role_arn\":\" \"}", "{\"aws_role_arn\":\"\"}", "{\"monthly_cost_usd\":11}" }) + { + var (plan, error) = Plan(PgRow(RoleA, idSet: true), json, AwsRoleAllowlist.Empty); + Assert.Null(error); + Assert.DoesNotContain(plan!.Sets, s => s.Column is "aws_role_arn" or "aws_external_id"); + Assert.Equal(RoleA, plan.NewAwsRoleArn); + } + } + + [Fact] + public void ANullRole_RemovesIt_EvenWithNoList_AndLeavesTheExternalIdToTheStore() + { + var (plan, error) = Plan(PgRow(RoleA, idSet: true), "{\"aws_role_arn\":null}", AwsRoleAllowlist.Empty); + Assert.Null(error); + var set = Assert.Single(plan!.Sets, s => s.Column == "aws_role_arn"); + Assert.Null(set.Value); + Assert.DoesNotContain(plan.Sets, s => s.Column == "aws_external_id"); + } + + [Fact] + public void TheSameRoleAsStored_IsNoChange_AndIsNotChecked() + { + var (plan, error) = Plan(PgRow(RoleA), $"{{\"aws_role_arn\":\"{RoleA}\"}}", AwsRoleAllowlist.Empty); + Assert.Null(error); + Assert.Empty(plan!.Sets); + } + + [Fact] + public void ChangingTheRole_WhileAnExternalIdIsStored_NeedsTheIdWithIt() + { + var list = AwsRoleAllowlist.From(["123456789012"]); + var (plan, error) = Plan(PgRow(RoleA, idSet: true), $"{{\"aws_role_arn\":\"{RoleB}\"}}", list); + Assert.Null(plan); + Assert.Equal(AwsRoleSettings.RoleChangeNeedsExternalIdMessage, error); + + var (replaced, replaceError) = Plan(PgRow(RoleA, idSet: true), $"{{\"aws_role_arn\":\"{RoleB}\",\"aws_external_id\":\"{TypedId}\"}}", list); + Assert.Null(replaceError); + Assert.Contains(replaced!.Sets, s => s.Column == "aws_external_id" && (string?)s.Value == TypedId); + + var (cleared, clearError) = Plan(PgRow(RoleA, idSet: true), $"{{\"aws_role_arn\":\"{RoleB}\",\"aws_external_id\":null}}", list); + Assert.Null(clearError); + Assert.Contains(cleared!.Sets, s => s.Column == "aws_external_id" && s.Value is null); + } + + [Fact] + public void AnExternalId_WithNoRole_IsRefused() + { + var (plan, error) = Plan(PgRow(), $"{{\"aws_external_id\":\"{TypedId}\"}}", AwsRoleAllowlist.From(["123456789012"])); + Assert.Null(plan); + Assert.Equal(AwsRoleSettings.ExternalIdNeedsRoleMessage, error); + } + + [Fact] + public void ARole_OnASqlServerTarget_IsRefused() + { + var (plan, error) = Plan(SqlServerRow(), $"{{\"aws_role_arn\":\"{RoleA}\"}}", AwsRoleAllowlist.From([RoleA])); + Assert.Null(plan); + Assert.Equal(AwsRoleSettings.RoleNeedsPostgresMessage, error); + } + + [Fact] + public void AMalformedRoleOrExternalId_IsRefusedByTheParse_WithTheSharedSentence() + { + var (_, roleError) = Edit.ParseEditChanges("{\"aws_role_arn\":\"not-an-arn\"}"); + Assert.Equal(AwsRoleSettings.InvalidRoleMessage, roleError); + var (_, idError) = Edit.ParseEditChanges("{\"aws_external_id\":\"has space\"}"); + Assert.Equal(AwsRoleSettings.InvalidExternalIdMessage, idError); + var (_, typeError) = Edit.ParseEditChanges("{\"aws_role_arn\":5}"); + Assert.Equal("aws_role_arn must be text, or null to remove the role.", typeError); + } + + [Fact] + public void TheCurrentValues_CarryTheRoleAndWhetherAnIdIsSet_NeverTheId() + { + var json = JsonSerializer.Serialize(Edit.CurrentValuesOf(PgRow(RoleA, idSet: true))); + Assert.Contains(RoleA, json, StringComparison.Ordinal); + Assert.Contains("\"aws_external_id_set\":true", json, StringComparison.Ordinal); + Assert.DoesNotContain("\"aws_external_id\"", json, StringComparison.Ordinal); + Assert.Contains("aws_external_id_set", Edit.ReadEditRowSql, StringComparison.Ordinal); + Assert.DoesNotContain("aws_external_id,", Edit.ReadEditRowSql.Replace("aws_external_id_set", "", StringComparison.Ordinal), StringComparison.Ordinal); + } + + [Fact] + public void TheFunctionCall_TakesSeventeenArguments_TheRoleAndIdLast() + { + var parameters = Edit.BuildEditFunctionParameters(41, Stamp, + [ + new Edit.EditColumnValue("aws_role_arn", "aws_role_arn", NpgsqlDbType.Text, RoleA), + new Edit.EditColumnValue("aws_external_id", "aws_external_id", NpgsqlDbType.Text, TypedId), + ]); + Assert.Equal(17, parameters.Count); + Assert.Contains("$17)", Edit.EditFunctionSql, StringComparison.Ordinal); + Assert.Equal(RoleA, parameters[15].Value); + Assert.Equal(TypedId, parameters[16].Value); + } + + [Fact] + public async Task TheStoresExternalIdNeededAnswers_BecomeTheSharedSentences() + { + foreach (var (kind, text) in new[] + { + (Edit.ServerEditWriteKind.ExternalIdNeeded, AwsRoleSettings.RoleChangeNeedsExternalIdMessage), + (Edit.ServerEditWriteKind.ExternalIdNeedsRole, AwsRoleSettings.ExternalIdNeedsRoleMessage), + }) + { + var store = new Store(PgRow(RoleA, idSet: true)) { WriteResult = kind }; + var answer = await Edit.EditServerCoreAsync(store, 41, "{\"monthly_cost_usd\":11}", Reachable, TestKeyRings.Healthy, null, CancellationToken.None); + using var doc = JsonDocument.Parse(answer); + Assert.Equal("invalid", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(text, doc.RootElement.GetProperty("message").GetString()); + } + } + + [Fact] + public async Task RemovingARole_IsAudited_WithOldAndNewRole_AndTheAnswerNamesBoth() + { + var store = new Store(PgRow(RoleA, idSet: true)); + var logger = new CapturingLogger(); + var answer = await Edit.EditServerCoreAsync(store, 41, "{\"aws_role_arn\":null}", Reachable, TestKeyRings.Healthy, logger, CancellationToken.None); + using var doc = JsonDocument.Parse(answer); + Assert.Equal("updated", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(RoleA, doc.RootElement.GetProperty("old_aws_role_arn").GetString()); + Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("new_aws_role_arn").ValueKind); + Assert.Contains(logger.Lines, l => l.Contains(RoleA, StringComparison.Ordinal) && l.Contains("(none)", StringComparison.Ordinal) && l.Contains("AWS role changed", StringComparison.Ordinal)); + } + + [Fact] + public async Task ATypedExternalId_NeverReachesAnAnswerOrALogLine() + { + /* The role is the stored one (no list check), the host moves and a password comes with it, so the probe runs; its + error quotes the typed ID. */ + var store = new Store(PgRow(RoleA, idSet: true)); + var logger = new CapturingLogger(); + var body = $"{{\"host\":\"pg-02.example.test\",\"password\":\"pw-secret-1\",\"aws_external_id\":\"{TypedId}\"}}"; + var failing = (Edit.ServerProbe)((_, _) => Task.FromResult( + new ConnectionProbeResult(false, 0, 0, null, false, false, false, false, "refused with " + TypedId))); + var answer = await Edit.EditServerCoreAsync(store, 41, body, failing, TestKeyRings.Healthy, logger, CancellationToken.None); + Assert.DoesNotContain(TypedId, answer, StringComparison.Ordinal); + /* The reply names the host and carries no driver text; the driver's text, with the typed ID removed, is in the log. */ + Assert.Contains("Could not connect to pg-02.example.test", answer, StringComparison.Ordinal); + Assert.Contains(logger.Lines, l => l.Contains("refused with [redacted]", StringComparison.Ordinal)); + + var ok = await Edit.EditServerCoreAsync(store, 41, body, Reachable, TestKeyRings.Healthy, logger, CancellationToken.None); + Assert.DoesNotContain(TypedId, ok, StringComparison.Ordinal); + Assert.All(logger.Lines, l => Assert.DoesNotContain(TypedId, l, StringComparison.Ordinal)); + } + + /* ---------------- the save path of a role-only edit ---------------- */ + + [Fact] + public void ARoleOnlyEdit_ReconnectsUnderTheNewRole_AndNeedsNoProbe() + { + /* The probe opens a PostgreSQL connection and makes no AWS call (DarlingServerConnector.ProbeAsync), so an edit + that moves nothing the connection uses has nothing to probe, the same as a display-name-only edit. What the + role changes is the service's connection: the answer says it reconnects. */ + var list = AwsRoleAllowlist.From([RoleA, RoleB]); + foreach (var (row, body) in new[] + { + (PgRow(), $"{{\"aws_role_arn\":\"{RoleA}\"}}"), + (PgRow(RoleA), $"{{\"aws_role_arn\":\"{RoleB}\"}}"), + (PgRow(RoleA), "{\"aws_role_arn\":null}"), + (PgRow(RoleA, idSet: true), "{\"aws_role_arn\":null}"), + (PgRow(RoleA, idSet: true), $"{{\"aws_role_arn\":\"{RoleB}\",\"aws_external_id\":\"{TypedId}\"}}"), + (PgRow(RoleA, idSet: true), $"{{\"aws_external_id\":\"{TypedId}\"}}"), + (PgRow(RoleA, idSet: true), "{\"aws_external_id\":null}"), + }) + { + var (plan, error) = Plan(row, body, list); + Assert.Null(error); + Assert.True(plan!.Reconnects, body); + Assert.False(plan.NeedsProbe, body); + } + + var (cost, _) = Plan(PgRow(RoleA, idSet: true), "{\"monthly_cost_usd\":11}", list); + Assert.False(cost!.Reconnects); + + var (same, _) = Plan(PgRow(RoleA, idSet: true), $"{{\"aws_role_arn\":\"{RoleA}\",\"monthly_cost_usd\":11}}", list); + Assert.False(same!.Reconnects); + } + + [Fact] + public async Task ARoleRemoval_WritesAndAnswersReconnectsTrue_WithNoProbe() + { + var store = new Store(PgRow(RoleA, idSet: true)); + var probed = 0; + var counting = (Edit.ServerProbe)((_, _) => { probed++; return Task.FromResult(new ConnectionProbeResult(true, 15, 3, "Enterprise", false, false, false, true, null)); }); + var answer = await Edit.EditServerCoreAsync(store, 41, "{\"aws_role_arn\":null}", counting, TestKeyRings.Healthy, null, CancellationToken.None); + using var doc = JsonDocument.Parse(answer); + Assert.Equal("updated", doc.RootElement.GetProperty("status").GetString()); + Assert.True(doc.RootElement.GetProperty("reconnects").GetBoolean()); + Assert.Equal(0, probed); + Assert.Equal(1, store.Writes); + } + + [Fact] + public async Task AConnectionEdit_ThatKeepsTheRole_ProbesWithTheRole_AndAnswersReconnects() + { + MonitoredServer? probedConfig = null; + var recording = (Edit.ServerProbe)((config, _) => + { + probedConfig = config; + return Task.FromResult(new ConnectionProbeResult(true, 15, 3, "Enterprise", false, false, false, true, null)); + }); + var store = new Store(PgRow(RoleA, idSet: true)); + var body = "{\"host\":\"pg-02.example.test\",\"password\":\"pw-secret-1\"}"; + var answer = await Edit.EditServerCoreAsync(store, 41, body, recording, TestKeyRings.Healthy, null, CancellationToken.None); + using var doc = JsonDocument.Parse(answer); + Assert.Equal("updated", doc.RootElement.GetProperty("status").GetString()); + Assert.True(doc.RootElement.GetProperty("reconnects").GetBoolean()); + Assert.True(doc.RootElement.GetProperty("tested").GetBoolean()); + Assert.NotNull(probedConfig); + Assert.Equal(RoleA, probedConfig!.AwsRoleArn); + } + + [Fact] + public async Task ARoleTheListRefuses_StopsTheEdit_BeforeAnyProbeOrWrite() + { + /* Current is Empty in these tests: a role set along with a host move is refused by the list first, so the probe + (which would connect to the new host) never runs and nothing is written. */ + var store = new Store(PgRow()); + var probed = 0; + var counting = (Edit.ServerProbe)((_, _) => { probed++; return Task.FromResult(new ConnectionProbeResult(true, 15, 3, "Enterprise", false, false, false, true, null)); }); + var body = $"{{\"host\":\"pg-02.example.test\",\"password\":\"pw-secret-1\",\"aws_role_arn\":\"{RoleA}\"}}"; + var answer = await Edit.EditServerCoreAsync(store, 41, body, counting, TestKeyRings.Healthy, null, CancellationToken.None); + using var doc = JsonDocument.Parse(answer); + Assert.Equal("invalid", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(0, probed); + Assert.Equal(0, store.Writes); + } + + [Fact] + public void TheWebAndMcpEdits_ShareOneCore_AndTheSameDefaultProbe() + { + var text = File.ReadAllText(Path.Combine(FindRepoRoot(), "Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpServerAdminTools.Edit.cs")); + var tool = text.IndexOf("public static Task EditServer(", StringComparison.Ordinal); + var byName = text.IndexOf("internal static async Task EditServerByNameAsync", StringComparison.Ordinal); + var byId = text.IndexOf("internal static async Task EditServerByIdAsync", StringComparison.Ordinal); + Assert.True(tool > 0 && byName > tool && byId > byName, "the tool, the by-name body and the web entry exist in order"); + Assert.Contains("DefaultProbeAsync", text[tool..byName], StringComparison.Ordinal); + Assert.Contains("EditServerCoreAsync(", text[byName..byId], StringComparison.Ordinal); + var webBody = text[byId..(byId + 1500)]; + Assert.Contains("EditServerCoreAsync(", webBody, StringComparison.Ordinal); + Assert.Contains("DefaultProbeAsync", webBody, StringComparison.Ordinal); + } + + /* ---------------- add ---------------- */ + + private static string AddJson(string extra) => + "[{\"host\":\"pg-03.example.test\",\"engine\":\"postgres\",\"auth\":\"SQL\",\"username\":\"monitor\",\"password\":\"pw-secret-1\"" + extra + "}]"; + + [Fact] + public void AnAdd_WithARole_NotOnTheList_OrWithNoList_IsInvalid_AndAListedOneIsKept() + { + var key = TestKeyRings.Healthy.Status; + var json = AddJson($",\"aws_role_arn\":\"{RoleA}\",\"aws_external_id\":\"{TypedId}\""); + + var (_, noList, _) = Edit.ParseRequest(json, key, false, AwsRoleAllowlist.Empty); + Assert.Contains(noList, r => r.Status == "invalid" && r.Detail.StartsWith("The web and MCP cannot set an AWS role", StringComparison.Ordinal)); + + var (_, notListed, _) = Edit.ParseRequest(json, key, false, AwsRoleAllowlist.From([OtherAccountRole])); + Assert.Contains(notListed, r => r.Status == "invalid" && r.Detail.StartsWith("That AWS role is not on the list", StringComparison.Ordinal)); + + var (entries, invalid, _) = Edit.ParseRequest(json, key, false, AwsRoleAllowlist.From(["123456789012"])); + Assert.Empty(invalid); + var entry = Assert.Single(entries); + Assert.Equal(RoleA, entry.ProbeConfig.AwsRoleArn); + Assert.Equal(TypedId, entry.ProbeConfig.AwsExternalId); + } + + [Fact] + public void AnAdd_WithNoRole_NeedsNoList_AndTheHostCommandLineIsNotHeldToIt() + { + var key = TestKeyRings.Healthy.Status; + var (plain, plainInvalid, _) = Edit.ParseRequest(AddJson(string.Empty), key, false, AwsRoleAllowlist.Empty); + Assert.Empty(plainInvalid); + Assert.Null(Assert.Single(plain).ProbeConfig.AwsRoleArn); + + var (cli, cliInvalid, _) = Edit.ParseRequest(AddJson($",\"aws_role_arn\":\"{RoleA}\""), key, true, AwsRoleAllowlist.Empty); + Assert.Empty(cliInvalid); + Assert.Equal(RoleA, Assert.Single(cli).ProbeConfig.AwsRoleArn); + } + + [Fact] + public void AnAdd_WithABadPair_OrOnSqlServer_IsInvalid_WithTheSharedSentences() + { + var key = TestKeyRings.Healthy.Status; + var list = AwsRoleAllowlist.From(["123456789012"]); + var (_, idOnly, _) = Edit.ParseRequest(AddJson($",\"aws_external_id\":\"{TypedId}\""), key, false, list); + Assert.Contains(idOnly, r => r.Detail == AwsRoleSettings.ExternalIdNeedsRoleMessage); + + var (_, badRole, _) = Edit.ParseRequest(AddJson(",\"aws_role_arn\":\"nope\""), key, false, list); + Assert.Contains(badRole, r => r.Detail == AwsRoleSettings.InvalidRoleMessage); + + var sqlServer = "[{\"host\":\"sql-09.example.test\",\"auth\":\"SQL\",\"username\":\"u\",\"password\":\"pw-secret-1\",\"aws_role_arn\":\"" + RoleA + "\"}]"; + var (_, onSql, _) = Edit.ParseRequest(sqlServer, key, false, list); + Assert.Contains(onSql, r => r.Detail == AwsRoleSettings.RoleNeedsPostgresMessage); + } + + [Fact] + public void TheInsert_WritesTheRoleAndExternalId_AsTheLastTwoParameters() + { + Assert.Contains("aws_role_arn, aws_external_id)", Edit.InsertServerSql, StringComparison.Ordinal); + Assert.Contains("$17, $18)", Edit.InsertServerSql, StringComparison.Ordinal); + } + + /* ---------------- the form's words ---------------- */ + + [Fact] + public void TheEditForm_UsesTheServicesOwnSentences_WordForWord() + { + var js = File.ReadAllText(Path.Combine(FindRepoRoot(), "Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "admin.js")); + foreach (var sentence in new[] + { + AwsRoleSettings.InvalidRoleMessage, AwsRoleSettings.InvalidExternalIdMessage, + AwsRoleSettings.ExternalIdNeedsRoleMessage, AwsRoleSettings.RoleChangeNeedsExternalIdMessage, + }) + { + Assert.Contains("\"" + sentence + "\"", js, StringComparison.Ordinal); + } + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "PerformanceMonitor.sln"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new InvalidOperationException("The repository root was not found."); + } + + /* ---------------- stubs ---------------- */ + + private static readonly Edit.ServerProbe Reachable = (_, _) => Task.FromResult( + new ConnectionProbeResult(true, 15, 3, "Enterprise", false, false, false, true, null)); + + private sealed class Store(Edit.ServerEditRow row) : Edit.IServerEditStore + { + public Edit.ServerEditWriteKind WriteResult { get; set; } = Edit.ServerEditWriteKind.Written; + + public int Writes { get; private set; } + + public Task ReadRowAsync(int serverId, CancellationToken cancellationToken) => + Task.FromResult(row); + + public Task> LoadOtherStorageKeysAsync(int serverId, CancellationToken cancellationToken) => + Task.FromResult(new List()); + + public Task WriteAsync( + int serverId, DateTime expectedModifiedAt, IReadOnlyList sets, string? newStorageKey, string? actualStorageKey, CancellationToken cancellationToken) => + Task.FromResult(CountedWrite()); + + private Edit.ServerEditWrite CountedWrite() + { + Writes++; + return new Edit.ServerEditWrite(WriteResult, Stamp.AddSeconds(1)); + } + } + + private sealed class CapturingLogger : ILogger + { + public List Lines { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Lines.Add(formatter(state, exception)); + } +} diff --git a/Darling/Darling.Tests/AwsRoleMessageTextTests.cs b/Darling/Darling.Tests/AwsRoleMessageTextTests.cs new file mode 100644 index 0000000000..fc7cb8e164 --- /dev/null +++ b/Darling/Darling.Tests/AwsRoleMessageTextTests.cs @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text.RegularExpressions; +using PerformanceMonitor.Darling.Service.Targets; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5452: the four texts an operator reads on collection health when a server's AWS role cannot be used. Each says what +/// happened, names the role ARN, says what to change, and never carries the external ID. +/// +public sealed class AwsRoleMessageTextTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + private const string Sentinel = "ext-7Hq2mZ9vLx"; + + private static AwsRoleAssumeException Make(AwsRoleAssumeKind kind, string? externalId) => kind switch + { + AwsRoleAssumeKind.NotAllowed => AwsRoleAssumeException.ForNotAllowed(new AwsRoleKey(Role, externalId), "us-east-1"), + AwsRoleAssumeKind.PartitionMismatch => AwsRoleAssumeException.ForPartitionMismatch(new AwsRoleKey(Role, externalId), "cn-north-1", "aws", "aws-cn"), + AwsRoleAssumeKind.Denied => AwsRoleAssumeException.ForStsFailure( + new AwsRoleKey(Role, externalId), "us-east-1", FakeSts.Error("AccessDenied", $"denied (ExternalId={externalId})")), + AwsRoleAssumeKind.RegionDisabled => AwsRoleAssumeException.ForStsFailure( + new AwsRoleKey(Role, externalId), "af-south-1", FakeSts.Error("RegionDisabledException", $"off (ExternalId={externalId})")), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + + /* What each message must tell the operator to change, as a phrase the text has to hold. */ + public static TheoryData Changes => new() + { + { AwsRoleAssumeKind.NotAllowed, ["allowedAwsRoles in darling.json", "restart the service", "clear the role"] }, + { AwsRoleAssumeKind.PartitionMismatch, ["partition", "Set a role from the target's partition", "clear the role", "host name"] }, + { AwsRoleAssumeKind.Denied, ["trust policy", "external ID", "sts:AssumeRole", "does not exist"] }, + { AwsRoleAssumeKind.RegionDisabled, ["Enable that region", "af-south-1", "set a role in a region that is enabled", "host name"] }, + }; + + [Theory] + [MemberData(nameof(Changes))] + public void EachRoleMessage_SaysWhatHappened_NamesTheRole_AndSaysWhatToChange(AwsRoleAssumeKind kind, string[] phrases) + { + var message = Make(kind, Sentinel).Message; + + Assert.Contains(Role, message, StringComparison.Ordinal); + Assert.Contains("Nothing was read this cycle", message, StringComparison.Ordinal); + foreach (var phrase in phrases) + { + Assert.Contains(phrase, message, StringComparison.Ordinal); + } + + Assert.DoesNotContain(Sentinel, message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [MemberData(nameof(Changes))] + public void EachRoleMessage_IsAnOrdinaryPlainSentence_NotARawSdkDump(AwsRoleAssumeKind kind, string[] phrases) + { + _ = phrases; + var message = Make(kind, Sentinel).Message; + + Assert.DoesNotContain("Amazon.", message, StringComparison.Ordinal); + Assert.DoesNotContain("Exception", message, StringComparison.Ordinal); + Assert.False(message.Contains('\n', StringComparison.Ordinal), "one line, as the collection health column shows it"); + } + + [Fact] + public void TheDeniedMessage_SaysWhetherAnExternalIdWasSent_AndNeverWhichOne() + { + var with = Make(AwsRoleAssumeKind.Denied, Sentinel).Message; + var without = Make(AwsRoleAssumeKind.Denied, null).Message; + + Assert.Contains("(with the external ID set on this server)", with, StringComparison.Ordinal); + Assert.Contains("(without an external ID)", without, StringComparison.Ordinal); + Assert.DoesNotContain(Sentinel, with, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ANoSourceIdentityMessage_NamesTheRoleAndWhatToChange_AndHoldsNoneOfTheSdksOwnText() + { + var raw = new InvalidOperationException(@"Profile corp-sso not found in C:\Users\svc\.aws\credentials; IMDS at 169.254.169.254 refused"); + + var ex = AwsRoleAssumeException.ForNoSourceIdentity(new AwsRoleKey(Role, Sentinel), "us-east-1", raw); + + Assert.Contains(Role, ex.Message, StringComparison.Ordinal); + Assert.Contains("Nothing was read this cycle", ex.Message, StringComparison.Ordinal); + Assert.Contains("restart the service", ex.Message, StringComparison.Ordinal); + Assert.Contains("SDK exception System.InvalidOperationException", ex.Message, StringComparison.Ordinal); + foreach (var text in new[] { "corp-sso", @"C:\Users", ".aws", "169.254", "IMDS" }) + { + Assert.DoesNotContain(text, ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain(text, ex.ToString(), StringComparison.Ordinal); + } + + Assert.Contains("corp-sso", ex.SourceDetail, StringComparison.Ordinal); + } + + [Fact] + public void ATransientMessage_NamesTheRole_AndHoldsNoneOfTheSdksOwnText() + { + var raw = FakeSts.Error("Throttling", @"Rate exceeded for C:\ProgramData\aws\config on host alpha-07"); + + var ex = AwsRoleAssumeException.ForStsFailure(new AwsRoleKey(Role, Sentinel), "us-east-1", raw); + + Assert.Equal(AwsRoleAssumeKind.Transient, ex.Kind); + Assert.Contains(Role, ex.Message, StringComparison.Ordinal); + Assert.Contains("AWS error code Throttling", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("ProgramData", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("alpha-07", ex.Message, StringComparison.Ordinal); + Assert.Contains("alpha-07", ex.SourceDetail, StringComparison.Ordinal); + } +} + +/// +/// #5452: Test Connection in the desktop viewer. The viewer sends a test_connect command; the service answers it with +/// DarlingServerConnector.ProbeAsync, which opens a PostgreSQL connection and reads facts from it. Neither the viewer +/// nor that path makes an AWS call, so the server's role changes nothing about the test. These pins keep it so: a test that +/// began to call AWS would have to carry the role and the external ID, and be held to the allowed-roles list. +/// +public sealed class AwsRoleTestConnectionTests +{ + private static string Repo() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "PerformanceMonitor.sln"))) + { + dir = dir.Parent; + } + + return dir?.FullName ?? throw new InvalidOperationException("The repository root was not found."); + } + + private static string Read(params string[] parts) => File.ReadAllText(Path.Combine(Repo(), Path.Combine(parts))); + + [Fact] + public void TheServiceSideOfTestConnection_MakesNoAwsCall() + { + var service = Path.Combine("Darling", "PerformanceMonitor.Darling.Service"); + foreach (var file in new[] { "DarlingServerConnector.cs", "DarlingCommandExecutor.cs" }) + { + var text = Read(service, file); + Assert.DoesNotMatch(new Regex(@"^\s*using\s+Amazon", RegexOptions.Multiline), text); + foreach (var name in new[] { "AwsRoleClients", "AwsRoleCredentialCache", "AssumeRole", "AmazonRDSClient", "AmazonPIClient", "AwsRoleKey", "RdsEndpointVerifier" }) + { + Assert.DoesNotContain(name, text, StringComparison.Ordinal); + } + } + } + + [Fact] + public void TheViewer_HasNoAwsSdk_SoItCannotCallAwsWithAnyCredentials() + { + var viewer = Path.Combine(Repo(), "Darling", "PerformanceMonitor.Darling.Viewer"); + var project = File.ReadAllText(Path.Combine(viewer, "PerformanceMonitor.Darling.Viewer.csproj")); + Assert.DoesNotContain("AWSSDK", project, StringComparison.Ordinal); + foreach (var file in Directory.EnumerateFiles(viewer, "*.cs", SearchOption.AllDirectories)) + { + if (file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal)) + { + continue; + } + + Assert.DoesNotMatch(new Regex(@"^\s*using\s+Amazon", RegexOptions.Multiline), File.ReadAllText(file)); + } + } + + [Fact] + public void TheProbe_ReadsNoRoleFromTheServerItTests() + { + var text = Read("Darling", "PerformanceMonitor.Darling.Service", "DarlingServerConnector.cs"); + var probe = text.IndexOf("public static async Task ProbeAsync", StringComparison.Ordinal); + Assert.True(probe > 0); + var end = text.IndexOf("public static string DescribeProbeFacts", probe, StringComparison.Ordinal); + Assert.DoesNotContain("AwsRole", text[probe..end], StringComparison.Ordinal); + Assert.DoesNotContain("AwsExternalId", text[probe..end], StringComparison.Ordinal); + } +} + +/// The role messages in the runbook's failure table read exactly as the service writes them (#5452). +public sealed class AwsRoleRunbookMessagesTests +{ + private const string Role = "arn:aws:iam::123456789012:role/darling-monitor"; + + private static string Runbook() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "PerformanceMonitor.sln"))) + { + dir = dir.Parent; + } + + return File.ReadAllText(Path.Combine( + dir?.FullName ?? throw new InvalidOperationException("The repository root was not found."), "docs", "postgres-first-target-runbook.md")); + } + + [Fact] + public void EachRoleMessage_IsQuotedInTheRunbookExactlyAsTheServiceWritesIt() + { + var runbook = Runbook(); + var withId = new AwsRoleKey(Role, "ext-7Hq2mZ9vLx"); + var messages = new[] + { + AwsRoleAssumeException.ForNotAllowed(withId, "us-east-1").Message, + AwsRoleAssumeException.ForPartitionMismatch(withId, "", "aws-cn", "aws").Message, + AwsRoleAssumeException.ForStsFailure(withId, "", FakeSts.Error("RegionDisabledException", "off")).Message, + AwsRoleAssumeException.ForStsFailure(withId, "us-east-1", FakeSts.Error("AccessDenied", "denied")).Message, + AwsRoleAssumeException.ForNoSourceIdentity(withId, "us-east-1", null).Message, + AwsRoleAssumeException.ForStsFailure(withId, "us-east-1", FakeSts.Error("Throttling", "slow")).Message, + }; + + foreach (var message in messages) + { + /* The table writes the role as , and the parts that vary (a code, the SDK note) as placeholders. */ + var quoted = message.Replace(Role, "", StringComparison.Ordinal); + quoted = Regex.Replace(quoted, @" \((?:SDK exception [^)]*|AWS error code [^)]*)\)\z", string.Empty); + Assert.Contains(quoted, runbook, StringComparison.Ordinal); + } + + /* The host-credentials message holds the AWS error code between its two quoted parts. */ + var rejected = AwsRoleAssumeException.ForStsFailure(withId, "us-east-1", FakeSts.Error("ExpiredToken", "old")).Message + .Replace(Role, "", StringComparison.Ordinal); + var split = rejected.IndexOf(" (ExpiredToken)", StringComparison.Ordinal); + Assert.True(split > 0); + Assert.Contains(rejected[..split], runbook, StringComparison.Ordinal); + Assert.Contains(rejected[(split + " (ExpiredToken)".Length)..], runbook, StringComparison.Ordinal); + } + + [Fact] + public void TheRunbooksSampleExternalId_IsOneTheExternalIdCheckRefuses() + { + var runbook = Runbook(); + var samples = Regex.Matches(runbook, "\"(?:awsExternalId|sts:ExternalId)\": \"([^\"]*)\""); + + Assert.NotEmpty(samples); + foreach (Match sample in samples) + { + Assert.NotNull(PerformanceMonitor.Darling.Storage.AwsRoleSettings.ValidateExternalId(sample.Groups[1].Value)); + } + } +} diff --git a/Darling/Darling.Tests/AwsRoleSettingsTests.cs b/Darling/Darling.Tests/AwsRoleSettingsTests.cs new file mode 100644 index 0000000000..fae1d92616 --- /dev/null +++ b/Darling/Darling.Tests/AwsRoleSettingsTests.cs @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// The per-server AWS role rules (#5452): is the one validator every surface that takes a +/// role ARN and an external ID shares, so its answers and its sentences are pinned here. +/// +public sealed class AwsRoleSettingsTests +{ + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("\t\n", null)] + [InlineData(" arn:aws:iam::123456789012:role/darling-monitor ", "arn:aws:iam::123456789012:role/darling-monitor")] + public void Normalize_TrimsAndTurnsBlankIntoNull(string? input, string? expected) + { + Assert.Equal(expected, AwsRoleSettings.Normalize(input)); + } + + [Theory] + [InlineData("arn:aws:iam::123456789012:role/darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/a")] + [InlineData("arn:aws-cn:iam::123456789012:role/darling-monitor")] + [InlineData("arn:aws-us-gov:iam::123456789012:role/darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/monitoring/darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/a/b/c/darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/name_with+=,.@-chars")] + [InlineData("arn:aws:iam::000000000000:role/darling")] + public void ValidateRole_AcceptsAnIamRoleArn(string role) + { + Assert.Null(AwsRoleSettings.ValidateRole(role)); + } + + [Fact] + public void ValidateRole_AcceptsAnUnsetRole() + { + Assert.Null(AwsRoleSettings.ValidateRole(null)); + } + + [Theory] + [InlineData("darling-monitor")] + [InlineData("arn:aws:iam::123456789012:user/darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/")] + [InlineData("arn:aws:iam::12345678901:role/darling-monitor")] + [InlineData("arn:aws:iam::1234567890123:role/darling-monitor")] + [InlineData("arn:aws:iam::12345678901a:role/darling-monitor")] + [InlineData("arn:aws:s3::123456789012:role/darling-monitor")] + [InlineData("arn:azure:iam::123456789012:role/darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/has space")] + [InlineData("arn:aws:iam::123456789012:role/monitoring//darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role//darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/darling-monitor/")] + [InlineData("arn:aws:iam::123456789012:role/darlingé")] + [InlineData("ARN:aws:iam::123456789012:role/darling-monitor")] + [InlineData("arn:aws:iam::123456789012:role/darling-monitor\nextra")] + [InlineData("arn:aws:iam::123456789012:role//x")] + [InlineData("arn:aws:iam::123456789012:role/path/darling")] + [InlineData("arn:aws:iam::123456789012:role/path>/darling")] + [InlineData("arn:aws:iam::123456789012:role/pa\"th/darling")] + [InlineData("arn:aws:iam::123456789012:role/pa'th/darling")] + [InlineData("arn:aws:iam::123456789012:role/pa&th/darling")] + [InlineData("arn:aws:iam::123456789012:role/pa`th/darling")] + [InlineData("arn:aws:iam::123456789012:role/pa AwsRoleSettings.RoleArnMaxLength); + Assert.Equal(AwsRoleSettings.InvalidRoleMessage, AwsRoleSettings.ValidateRole(role)); + } + + [Theory] + [InlineData("ab")] + [InlineData("0123456789")] + [InlineData("tenant-1234:prod/us_east+1=x,y.z@corp")] + [InlineData("A-b_C+d=e,f.g@h:i/j-k")] + public void ValidateExternalId_AcceptsAwsCharactersAtTwoToTwelveTwentyFour(string externalId) + { + Assert.Null(AwsRoleSettings.ValidateExternalId(externalId)); + } + + [Fact] + public void ValidateExternalId_AcceptsAnUnsetOneAndTheBounds() + { + Assert.Null(AwsRoleSettings.ValidateExternalId(null)); + Assert.Null(AwsRoleSettings.ValidateExternalId(new string('x', AwsRoleSettings.ExternalIdMaxLength))); + Assert.Equal( + AwsRoleSettings.InvalidExternalIdMessage, + AwsRoleSettings.ValidateExternalId(new string('x', AwsRoleSettings.ExternalIdMaxLength + 1))); + } + + [Theory] + [InlineData("x")] + [InlineData("has space")] + [InlineData("tab\there")] + [InlineData("semi;colon")] + [InlineData("quote'quote")] + [InlineData("café-id")] + [InlineData("new\nline")] + [InlineData("star*")] + public void ValidateExternalId_RefusesTooShortAndAnyOtherCharacter(string externalId) + { + Assert.Equal(AwsRoleSettings.InvalidExternalIdMessage, AwsRoleSettings.ValidateExternalId(externalId)); + } + + [Fact] + public void ValidatePair_ReportsFormatFirst_ThenAnExternalIdWithNoRole() + { + const string role = "arn:aws:iam::123456789012:role/darling-monitor"; + + Assert.Null(AwsRoleSettings.ValidatePair(null, null)); + Assert.Null(AwsRoleSettings.ValidatePair(role, null)); + Assert.Null(AwsRoleSettings.ValidatePair(role, "tenant-1")); + + Assert.Equal(AwsRoleSettings.ExternalIdNeedsRoleMessage, AwsRoleSettings.ValidatePair(null, "tenant-1")); + Assert.Equal(AwsRoleSettings.InvalidRoleMessage, AwsRoleSettings.ValidatePair("nope", "tenant-1")); + Assert.Equal(AwsRoleSettings.InvalidExternalIdMessage, AwsRoleSettings.ValidatePair(role, "x")); + } + + [Theory] + /* old, new, stored, sent, needs */ + [InlineData(null, "arn:aws:iam::123456789012:role/b", true, false, true)] + [InlineData("arn:aws:iam::123456789012:role/a", "arn:aws:iam::123456789012:role/b", true, false, true)] + [InlineData("arn:aws:iam::123456789012:role/a", "arn:aws:iam::123456789012:role/b", true, true, false)] + [InlineData("arn:aws:iam::123456789012:role/a", "arn:aws:iam::123456789012:role/b", false, false, false)] + [InlineData("arn:aws:iam::123456789012:role/a", "arn:aws:iam::123456789012:role/a", true, false, false)] + [InlineData("arn:aws:iam::123456789012:role/a", "arn:aws:iam::123456789012:role/A", true, false, true)] + [InlineData("arn:aws:iam::123456789012:role/a", null, true, false, false)] + [InlineData(null, null, true, false, false)] + public void RoleChangeNeedsExternalId_OnlyForANewRoleOverAStoredIdThatWasNotSent( + string? oldRole, string? newRole, bool stored, bool sent, bool expected) + { + Assert.Equal(expected, AwsRoleSettings.RoleChangeNeedsExternalId(oldRole, newRole, stored, sent)); + } + + [Fact] + public void TheSentences_ArePinned_AndNameNoRealAccount() + { + Assert.Equal( + "The AWS role must be an IAM role ARN, such as arn:aws:iam::123456789012:role/darling-monitor.", + AwsRoleSettings.InvalidRoleMessage); + Assert.Equal( + "The external ID must be 2 to 1224 characters: letters, digits and _ + = , . @ : / - with no spaces.", + AwsRoleSettings.InvalidExternalIdMessage); + Assert.Equal( + "An external ID needs an AWS role ARN. Set the role, or clear the external ID.", + AwsRoleSettings.ExternalIdNeedsRoleMessage); + Assert.Equal( + "An AWS role applies only to a PostgreSQL target on Amazon RDS or Aurora.", + AwsRoleSettings.RoleNeedsPostgresMessage); + Assert.Equal( + "Changing the AWS role needs the external ID with it: send the external ID again, or clear it.", + AwsRoleSettings.RoleChangeNeedsExternalIdMessage); + } +} diff --git a/Darling/Darling.Tests/AwsStsTestSupport.cs b/Darling/Darling.Tests/AwsStsTestSupport.cs new file mode 100644 index 0000000000..4185c37cb8 --- /dev/null +++ b/Darling/Darling.Tests/AwsStsTestSupport.cs @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Amazon.SecurityToken; +using Amazon.SecurityToken.Model; +using Microsoft.Extensions.Logging; + +namespace Darling.Tests; + +/// +/// A fake STS for the assumed-role credential tests (#5452): no AWS, no network. It answers +/// AssumeRoleAsync through a handler and records every request, the region it was built for and the credentials +/// it was given. Every other member of the interface is unsupported, which fails a test loudly if the code under test +/// starts calling something else. +/// +public class FakeStsProxy : DispatchProxy +{ + internal Func> Handler { get; set; } = null!; + + internal List Requests { get; set; } = null!; + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + switch (targetMethod?.Name) + { + case "AssumeRoleAsync": + var request = (AssumeRoleRequest)args![0]!; + lock (Requests) + { + Requests.Add(request); + } + + return Handler(request, (CancellationToken)args[1]!); + case "Dispose": + return null; + default: + throw new NotSupportedException("FakeSts does not implement " + targetMethod?.Name); + } + } +} + +/// Builds clients and the hooks the credentials class takes. +internal sealed class FakeSts +{ + private readonly object _gate = new(); + + public FakeSts(Func>? handler = null) + { + Handler = handler ?? ((_, _) => Task.FromResult(Grant())); + } + + public Func> Handler { get; set; } + + public List Requests { get; } = new(); + + public List Regions { get; } = new(); + + public List SourceCredentialsSeen { get; } = new(); + + public int Calls + { + get + { + lock (Requests) + { + return Requests.Count; + } + } + } + + public Func Factory => (region, credentials) => + { + lock (_gate) + { + Regions.Add(region.SystemName); + SourceCredentialsSeen.Add(credentials.GetCredentials()); + } + + var proxy = DispatchProxy.Create(); + var fake = (FakeStsProxy)(object)proxy; + fake.Handler = (request, token) => Handler(request, token); + fake.Requests = Requests; + return proxy; + }; + + /// A source of credentials that always answers with the same example keys. + public static Func> Source { get; } = + _ => Task.FromResult(new BasicAWSCredentials("AKIAEXAMPLESOURCE", "example-source-secret")); + + /// An STS response with example credentials. is deliberately settable: the code must not read it. + public static AssumeRoleResponse Grant(string token = "example-session-token", DateTime? expiration = null) => new() + { + Credentials = new Credentials + { + AccessKeyId = "ASIAEXAMPLEASSUMED", + SecretAccessKey = "example-assumed-secret", + SessionToken = token, + Expiration = expiration, + }, + }; + + /// An STS error with an AWS error code. + public static AmazonSecurityTokenServiceException Error(string code, string message) => new(message) + { + ErrorCode = code, + }; +} + +/// A clock that moves only when a test moves it. +internal sealed class ManualTimeProvider : TimeProvider +{ + private DateTimeOffset _now; + + public ManualTimeProvider(DateTimeOffset? start = null) + { + _now = start ?? new DateTimeOffset(2026, 10, 7, 12, 0, 0, TimeSpan.Zero); + } + + public override DateTimeOffset GetUtcNow() => _now; + + public void Advance(TimeSpan by) => _now += by; +} + +/// A logger that keeps every formatted line and its exception. +internal sealed class CapturingAwsLogger : ILogger +{ + public List Lines { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + lock (Lines) + { + Lines.Add($"{logLevel}: {formatter(state, exception)}" + (exception is null ? string.Empty : " | " + exception)); + } + } + + public string All + { + get + { + lock (Lines) + { + return string.Join("\n", Lines); + } + } + } +} diff --git a/Darling/Darling.Tests/AzureFallbackMessageTests.cs b/Darling/Darling.Tests/AzureFallbackMessageTests.cs new file mode 100644 index 0000000000..c47021c204 --- /dev/null +++ b/Darling/Darling.Tests/AzureFallbackMessageTests.cs @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5498: when master cannot be read and the registration names no database, the thrown text puts the firewall first. +/// Error 40615 means the Azure firewall did not allow the client's IP address (a server-level block causes it as +/// readily as a database-only rule), so the text names that and the firewall rule as the fix. Setting a database is +/// only the fix for the master-only case. The 3.10 Azure release test hit this with a server-level rule removed and +/// was told to set a database. The fallback behavior itself (throw when there is nothing to fall back to, use the +/// named database otherwise) is unchanged and pinned here beside the wording. +/// +public sealed class AzureFallbackMessageTests +{ + private static DarlingCollectorRunner Runner() + => new(NpgsqlDataSource.Create("Host=localhost;Database=unused"), new CollectorDeltaCalculator(), NullLogger.Instance); + + private static ServerRuntime Server() => new() + { + Config = new MonitoredServer { Name = "AzureTest1007", Host = "example.database.windows.net" }, + ConnectionString = "Server=tcp:example.database.windows.net,1433;Initial Catalog=master;Encrypt=True", + Target = new CollectorTargetInfo { IsAzureSqlDb = true }, + StorageName = "example.database.windows.net", + ServerId = 5498, + }; + + [Theory] + [InlineData("master")] + [InlineData("")] + [InlineData(null)] + public void NoDatabaseToFallBackTo_NamesTheFirewallFirst(string? targetDb) + { + var ex = Assert.Throws( + () => Runner().FallbackDatabaseList(Server(), targetDb, reason: "master DB inaccessible (SQL error 40615)")); + + var message = ex.Message; + Assert.StartsWith("master DB inaccessible (SQL error 40615), and this connection has no target database", message, StringComparison.Ordinal); + + var firewall = message.IndexOf("firewall did not allow this client's IP address", StringComparison.Ordinal); + var rule = message.IndexOf("add a firewall rule", StringComparison.Ordinal); + var setDatabase = message.IndexOf("set a database for 'AzureTest1007'", StringComparison.Ordinal); + + Assert.True(firewall > 0, message); + Assert.True(rule > firewall, message); + Assert.Contains("either a server-level rule or a database-level rule in master", message, StringComparison.Ordinal); + Assert.True(setDatabase > rule, "Setting a database comes after the firewall, as the master-only case's fix."); + Assert.Contains("can open a user database but not master", message, StringComparison.Ordinal); + } + + [Fact] + public void ANamedDatabaseIsStillTheFallback() + { + var databases = Runner().FallbackDatabaseList(Server(), "AdventureWorks", reason: "master DB inaccessible (SQL error 40615)"); + + Assert.Equal(new List { "AdventureWorks" }, databases); + } +} diff --git a/Darling/Darling.Tests/AzureForeignStatePruneTests.cs b/Darling/Darling.Tests/AzureForeignStatePruneTests.cs index 5c0c33b460..8cc2cc760c 100644 --- a/Darling/Darling.Tests/AzureForeignStatePruneTests.cs +++ b/Darling/Darling.Tests/AzureForeignStatePruneTests.cs @@ -138,7 +138,10 @@ their orphaned rows wholesale — a dropped-database prune has nothing left to o k => k.Prefix == QueryStoreOpenIntervalState.WatermarkKeyPrefix); /* Nothing may sit outside both lists — a server-scoped key added to PrunableKeys by reflex would be - deleted every cycle. */ - Assert.Empty(QueryStorePerDatabaseState.NotKeyedByDatabase); + deleted every cycle. The one member is the drop record of the long-query session older versions shared + between installs (#4961): its server-scope key is the bare prefix, which a prune that rebuilds keys as + prefix + database name would delete every cycle. A new member is a decision, so the list is pinned + whole rather than checked for containment. */ + Assert.Equal(new[] { LegacyLongQuerySession.StateKeyPrefix }, QueryStorePerDatabaseState.NotKeyedByDatabase); } } diff --git a/Darling/Darling.Tests/AzureMasterAnalysisScopeLiveTests.cs b/Darling/Darling.Tests/AzureMasterAnalysisScopeLiveTests.cs index d64deb6c6d..60766b52a8 100644 --- a/Darling/Darling.Tests/AzureMasterAnalysisScopeLiveTests.cs +++ b/Darling/Darling.Tests/AzureMasterAnalysisScopeLiveTests.cs @@ -69,7 +69,7 @@ INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major VALUES ($1, $2, $2, TRUE, 16, now()::timestamp, now()::timestamp) ON CONFLICT (server_id) DO UPDATE SET is_enabled = TRUE", ct, ServerId, ServerName); - var end = new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % TimeSpan.TicksPerMinute), DateTimeKind.Unspecified).AddMinutes(-1); + var end = new DateTime(DateTime.UtcNow.Ticks / TimeSpan.TicksPerMinute * TimeSpan.TicksPerMinute, DateTimeKind.Unspecified).AddMinutes(-1); var start = end.AddHours(-4); await Exec(connection, "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type, delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms) VALUES ($1,$2,$3,$4,'OLD',1,1,0)", ct, CollectionIdGenerator.Next(), end.AddDays(-3), ServerId, ServerName); @@ -231,7 +231,7 @@ await Exec(connection, @" INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major_version, created_date, modified_date) VALUES ($1, $2, $2, TRUE, 16, now()::timestamp, now()::timestamp) ON CONFLICT (server_id) DO UPDATE SET is_enabled = TRUE", ct, ServerId, ServerName); - var end = new DateTime(DateTime.UtcNow.Ticks - (DateTime.UtcNow.Ticks % TimeSpan.TicksPerMinute), DateTimeKind.Unspecified).AddMinutes(-1); + var end = new DateTime(DateTime.UtcNow.Ticks / TimeSpan.TicksPerMinute * TimeSpan.TicksPerMinute, DateTimeKind.Unspecified).AddMinutes(-1); var start = end.AddHours(-4); for (var m = 0; m <= 240; m += 15) await Exec(connection, "INSERT INTO wait_stats (collection_id, collection_time, server_id, server_name, wait_type, delta_waiting_tasks, delta_wait_time_ms, delta_signal_wait_time_ms) VALUES ($1,$2,$3,$4,'CXPACKET',1,10,0)", diff --git a/Darling/Darling.Tests/AzureSiblingGrowthTests.cs b/Darling/Darling.Tests/AzureSiblingGrowthTests.cs index e047c8106e..89194dd5dd 100644 --- a/Darling/Darling.Tests/AzureSiblingGrowthTests.cs +++ b/Darling/Darling.Tests/AzureSiblingGrowthTests.cs @@ -61,18 +61,32 @@ private static string CteBody(string sql, string name) } [Fact] - public void ViewerStorageGrowth_LeavesTheOldShapeRowsOutOfTheLatestAnd7dAnd30dSums() + public void ViewerStorageGrowth_LeavesTheOldShapeRowsOutOfTheLatestSum_AndEverySiblingRowOutOfThe7dAnd30dSums() { var sql = ViewerDataService.StorageGrowthSql; - foreach (var sum in new[] { "latest", "past_7d", "past_30d" }) + Assert.True( + Squash(CteBody(sql, "latest")).Contains(AzureSiblingDatabaseSize.ExcludePreFixRows, StringComparison.Ordinal), + "The latest sum does not leave the old-shape sibling rows out."); + + // #5498: the newest side holds data plus log per database now, so a past side that kept the old data-only + // sibling rows would read the log as growth. + foreach (var sum in new[] { "past_7d", "past_30d" }) { Assert.True( - Squash(CteBody(sql, sum)).Contains(AzureSiblingDatabaseSize.ExcludePreFixRows, StringComparison.Ordinal), - $"The {sum} sum does not leave the old-shape sibling rows out."); + Squash(CteBody(sql, sum)).Contains(AzureSiblingDatabaseSize.ExcludeAllSiblingRows, StringComparison.Ordinal), + $"The {sum} sum does not leave every sibling row out."); } } + [Fact] + public void ExcludeAllSiblingRows_IsTheRowTestWithoutTheUsedSpaceCondition() + { + Assert.Equal( + "NOT (file_id IS NULL AND COALESCE(file_name, '') = '(whole database)')", + AzureSiblingDatabaseSize.ExcludeAllSiblingRows); + } + /// /// A sibling's size is data space only, so the latest CTE flags a database that has the one row another database /// gets, with a bool_or of the shared row test. Lite's AzureSiblingGrowthTests run the twin query on a @@ -114,10 +128,10 @@ public void ViewerStorageGrowth_ReadsTheTwoFlagsLast_SoNoOtherColumnMoves() Squash(ViewerDataService.StorageGrowthSql), StringComparison.Ordinal); - var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Storage.cs"); - Assert.Contains("GrowthPct30d = reader.IsDBNull(7) ? null : Convert.ToDecimal(reader.GetValue(7)),", source, StringComparison.Ordinal); - Assert.Contains("HasSiblingRow = !reader.IsDBNull(8) && reader.GetBoolean(8)", source, StringComparison.Ordinal); - Assert.Contains("HasLogServiceFile = !reader.IsDBNull(9) && reader.GetBoolean(9)", source, StringComparison.Ordinal); + var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "DarlingFinOpsStorageGrowthReader.cs"); + Assert.Contains("GrowthPct30d: reader.IsDBNull(7) ? null : Convert.ToDecimal(reader.GetValue(7)),", source, StringComparison.Ordinal); + Assert.Contains("HasSiblingRow: !reader.IsDBNull(8) && reader.GetBoolean(8)", source, StringComparison.Ordinal); + Assert.Contains("HasLogServiceFile: !reader.IsDBNull(9) && reader.GetBoolean(9)", source, StringComparison.Ordinal); } /// diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs index 1c9e5fb464..122e79ff9a 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHardwareTests.cs @@ -166,7 +166,7 @@ public void WebStatRenderer_DropsTilesByTheirCondition_OnEdition5_AndKeepsEveryT Process proc; try { proc = Process.Start(psi)!; } - catch (Win32Exception) { return; } // Node is not installed; the source pins above still hold the change in place. + catch (Win32Exception) { Assert.Skip("Node is not installed, so the shipped page script cannot be run."); throw; } // The source pins above still hold the change in place. using (proc) { @@ -235,18 +235,21 @@ public void FinOpsUtilizationCard_AsksTheSharedRule_ForTheWordsAroundItsMemoryFi { var tab = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.Loaders.cs"); var xaml = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "FinOpsTab.xaml"); - var read = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); + var read = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "DarlingFinOpsUtilizationReader.cs"); + var figures = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "FinOpsUtilizationFigures.cs"); - Assert.Contains("ServerHardwareScope.HardwareIsTheHosts(data.EngineEdition)", tab, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.HardwareIsTheHosts(d.EngineEdition)", figures, StringComparison.Ordinal); + Assert.Contains("FinOpsUtilizationFigures.Explanation(", tab, StringComparison.Ordinal); Assert.Contains("FinOpsPhysicalMemoryCaption.Text = ServerHardwareScope.PhysicalMemoryCaption(data.EngineEdition);", tab, StringComparison.Ordinal); Assert.Contains("x:Name=\"FinOpsPhysicalMemoryCaption\"", xaml, StringComparison.Ordinal); - Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", tab, StringComparison.Ordinal); - Assert.Contains("ServerHardwareScope.RightSizedExplanation(", tab, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.OverProvisionedExplanation(", figures, StringComparison.Ordinal); + Assert.Contains("ServerHardwareScope.RightSizedExplanation(", figures, StringComparison.Ordinal); Assert.DoesNotContain("of physical RAM", tab, StringComparison.Ordinal); + Assert.DoesNotContain("of physical RAM", figures, StringComparison.Ordinal); /* The CPU count is resolved through the edition (AzureSqlDatabaseHostMathTests pins the CASE): off edition 5 it is still COALESCE(vcore_count, cpu_count), and the edition still rides along for the card. */ Assert.Contains("ELSE COALESCE(vcore_count, cpu_count) END AS cpu_count, engine_edition", read, StringComparison.Ordinal); - Assert.Contains("EngineEdition = reader.IsDBNull(16)", read, StringComparison.Ordinal); + Assert.Contains("EngineEdition: reader.IsDBNull(16)", read, StringComparison.Ordinal); } [Fact] diff --git a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs index bb0fc0d318..68ee30bb21 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseHostMathTests.cs @@ -30,6 +30,7 @@ namespace Darling.Tests; /// (editions 1 to 4) and Managed Instance (8) behave exactly as before, and every test has that twin. Lite.Tests pins the /// same table for the other app, in the same words. /// +[Trait("Reads", "Lite")] public sealed class AzureSqlDatabaseHostMathTests { private static readonly DateTime s_start = new(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc); diff --git a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs index 7fb112600a..a4032554b4 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseMemoryScopeTests.cs @@ -239,20 +239,32 @@ public void MemoryRecommendation_NeverDividesByServerPropertiesMemory_SoItCannot "{percent} of {n}GB RAM". Its divisor is util.PhysicalMemoryMb, read from memory_stats through the utilization read, and the rules file reads no physical-memory column of its own. */ var rules = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Recommendations.cs"); + var reader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "DarlingFinOpsRecommendationsReader.cs"); - Assert.Contains("of {util.PhysicalMemoryMb / 1024}GB RAM", rules, StringComparison.Ordinal); + var figures = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "FinOpsRecommendationFigures.cs"); + + Assert.Contains("of {util.PhysicalMemoryMb / 1024}GB RAM", figures, StringComparison.Ordinal); Assert.False( Regex.IsMatch(rules, @"physical_memory_mb"), "the recommendation rules must not read a physical-memory column of their own"); + Assert.False( + Regex.IsMatch(reader, @"physical_memory_mb"), + "the recommendation reader must not read a physical-memory column of its own"); + Assert.False( + Regex.IsMatch(figures, @"physical_memory_mb"), + "the recommendation figures must not read a physical-memory column of their own"); } [Fact] public void MemoryAndVmRules_OnAzureSqlDatabase_SayWhyTheyStandDown_AndDoNotCallTheMemoryTheHosts() { - var rules = Flatten(ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Recommendations.cs")); + var rules = Flatten(ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "DarlingFinOpsRecommendationsReader.cs")); + var viewerRules = Flatten(ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Recommendations.cs")); Assert.DoesNotContain("reports the HOST's memory", rules, StringComparison.Ordinal); Assert.DoesNotContain("its memory figure is the host's", rules, StringComparison.Ordinal); + Assert.DoesNotContain("reports the HOST's memory", viewerRules, StringComparison.Ordinal); + Assert.DoesNotContain("its memory figure is the host's", viewerRules, StringComparison.Ordinal); Assert.Contains("its memory comes with its service objective and cannot be resized on its own", rules, StringComparison.Ordinal); Assert.Contains("its cores and memory come with its service objective", rules, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs index 897466a590..e82f0cdfec 100644 --- a/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs +++ b/Darling/Darling.Tests/AzureSqlDatabaseOwnFiguresTests.cs @@ -15,6 +15,7 @@ using PerformanceMonitor.Darling.Analysis; using PerformanceMonitor.Darling.Service.Mcp; using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Storage.FinOps; using PerformanceMonitor.Darling.Viewer; using PerformanceMonitor.PlanAnalysis; using Xunit; @@ -488,20 +489,23 @@ public void FinOpsTab_AsksTheSharedRules_ForTheCpuUnit_TheWorkerThreadsCard_TheH Assert.DoesNotContain("$\"{data.CurrentWorkersCount:N0} / {data.MaxWorkersCount:N0}\"", tab, StringComparison.Ordinal); Assert.Contains( - "FinOpsHealthScoreBorder.ToolTip = data.HasCpuSample ? null : ServerHardwareScope.HealthScoreWithoutCpuNote;", + "FinOpsHealthScoreBorder.ToolTip = data.HasCpuSample ? null : FinOpsHealthCalculator.NoScoreNote;", tab, StringComparison.Ordinal); - Assert.Contains( - "int? cpuScore = item.AvgCpuPct is decimal avgCpu ? FinOpsHealthCalculator.CpuScore(avgCpu) : null;", - tab, StringComparison.Ordinal); + // The inventory score is the fleet read's, the Utilization view's own rule: no CPU sample is a dash, never a default. + Assert.Contains("item.HealthScore = row.HealthScore;", tab, StringComparison.Ordinal); Assert.DoesNotContain("item.AvgCpuPct ?? 0m", tab, StringComparison.Ordinal); + + var reader = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "DarlingFinOpsInventoryReader.cs"); + Assert.Contains("return FinOpsUtilizationFigures.HealthScore(", reader, StringComparison.Ordinal); + Assert.Contains("if (reader.IsDBNull(1) || reader.IsDBNull(16)) return null;", reader, StringComparison.Ordinal); } [Fact] public void WorkerReads_KeepANullInUseCountNull_InAllThreePlaces() { - var utilization = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Utilization.cs"); - var inventory = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.FinOps.Inventory.cs"); + var utilization = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "DarlingFinOpsUtilizationReader.cs"); + var inventory = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "FinOps", "DarlingFinOpsInventoryReader.cs"); /* Point-in-time read, 7-day trend and fleet read: the in-use count is read as NULL, not coalesced to 0. */ Assert.Contains("int? currentWorkers = reader.IsDBNull(10) ? null : Convert.ToInt32(reader.GetValue(10));", utilization, StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/BaselineDiscontinuityRenderTests.cs b/Darling/Darling.Tests/BaselineDiscontinuityRenderTests.cs index 9db21827a6..b5be6f7d20 100644 --- a/Darling/Darling.Tests/BaselineDiscontinuityRenderTests.cs +++ b/Darling/Darling.Tests/BaselineDiscontinuityRenderTests.cs @@ -35,7 +35,7 @@ public sealed class BaselineDiscontinuityRenderTests { "get_memory_trend", "get_perfmon_trend", "get_file_io_trend", "get_query_trend", "get_query_duration_trend", "get_procedure_duration_trend", "get_query_store_duration_trend", - "get_wait_trend", + "get_wait_trend", "get_server_trend", }; private static (string Name, MethodInfo Method)[] AllDarlingTools() => typeof(DarlingMcpTrendTools).Assembly.GetTypes() @@ -76,7 +76,8 @@ public void TheWaitTrend_LivesInDataTools_AndTheSevenInTrendTools() var byType = AllDarlingTools().ToDictionary(t => t.Name, t => t.Method.DeclaringType!, StringComparer.Ordinal); Assert.Equal(typeof(DarlingMcpDataTools), byType["get_wait_trend"]); - foreach (var name in Roster.Where(n => n != "get_wait_trend")) + Assert.Equal(typeof(DarlingMcpServerTrendTools), byType["get_server_trend"]); + foreach (var name in Roster.Where(n => n != "get_wait_trend" && n != "get_server_trend")) { Assert.Equal(typeof(DarlingMcpTrendTools), byType[name]); } diff --git a/Darling/Darling.Tests/BaselineSupplyTests.cs b/Darling/Darling.Tests/BaselineSupplyTests.cs index eb28edf8ee..f48f6b3c7a 100644 --- a/Darling/Darling.Tests/BaselineSupplyTests.cs +++ b/Darling/Darling.Tests/BaselineSupplyTests.cs @@ -514,14 +514,19 @@ public void Successors_TookTheLegacyPositions_SoThePhaseGridDidNotMove() Assert.Equal(TimescaleSupport.WaitStatsIntervalBaselineView, TimescaleSupport.BaselineAggregates[1].View); /* The order's count is the hourly registry plus the seven baselines — 6 + 7 = 13 since #3653's LC - freeze (was 9 + 7 = 16 during Q12) — and the baseline pair contributes exactly its two positions to - it, no more. */ + freeze (was 9 + 7 = 16 during Q12, and + 8 + 7 = 15 at #5329) — and the baseline pair contributes exactly its two positions to it, no more. */ Assert.Equal(TimescaleSupport.HourlyAggregates.Length + 7, TimescaleSupport.HourlyRefreshPhaseOrder.Count); - Assert.Equal(13, TimescaleSupport.HourlyRefreshPhaseOrder.Count); - Assert.Equal(3, TimescaleSupport.RefreshPhaseMinutesFor(TimescaleSupport.PerfmonIntervalBaselineView)); - Assert.Equal(5, TimescaleSupport.RefreshPhaseMinutesFor(TimescaleSupport.WaitStatsIntervalBaselineView)); - Assert.Equal(15, TimescaleSupport.HeaviestRefreshStartMinute); - Assert.Equal(1050, TimescaleSupport.RefreshSlotWarningSeconds); + /* #5329: 8 + 7 = 15 now that the two io hourlies joined the registry, so the grid re-derives: 14 light + members spread over a 13-minute band, the heaviest refresh at :17 with a 19-minute window, so the watch + line is 950 s. The light band is re-laid (the unbounded-cardinality members sit a guard's width apart in + it, and the band now has 14 positions), which moves the baseline pair from minutes 3 and 5 to 5 and 6; the minute map stays + injective (TimescaleSupportTests pins that and carries the derivation). */ + Assert.Equal(15, TimescaleSupport.HourlyRefreshPhaseOrder.Count); + Assert.Equal(5, TimescaleSupport.RefreshPhaseMinutesFor(TimescaleSupport.PerfmonIntervalBaselineView)); + Assert.Equal(6, TimescaleSupport.RefreshPhaseMinutesFor(TimescaleSupport.WaitStatsIntervalBaselineView)); + Assert.Equal(17, TimescaleSupport.HeaviestRefreshStartMinute); + Assert.Equal(950, TimescaleSupport.RefreshSlotWarningSeconds); } /// diff --git a/Darling/Darling.Tests/BenefitScorerWiringTests.cs b/Darling/Darling.Tests/BenefitScorerWiringTests.cs index 8cecc35769..6a302a4ca0 100644 --- a/Darling/Darling.Tests/BenefitScorerWiringTests.cs +++ b/Darling/Darling.Tests/BenefitScorerWiringTests.cs @@ -22,6 +22,8 @@ namespace Darling.Tests; /// is now the one place that runs then , /// and every entry point that used to call the analyzer directly calls the pipeline instead. /// +[Trait("Stage", "Guard")] +[Trait("Reads", "Lite")] public sealed class BenefitScorerWiringTests { /// diff --git a/Darling/Darling.Tests/BlockingDatabaseFilterLiveTests.cs b/Darling/Darling.Tests/BlockingDatabaseFilterLiveTests.cs new file mode 100644 index 0000000000..313cf5a592 --- /dev/null +++ b/Darling/Darling.Tests/BlockingDatabaseFilterLiveTests.cs @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5244 (PR3, lane R1): the blocking reads take a SET of databases. get_blocking and its reader filter BOTH arms (the XE +/// reports and the DMV fallback), get_blocked_process_xml filters the XE-with-XML read, and get_blocking_trend filters +/// both arms of the per-minute trend. A call with [A, B] over a seed of A, B and C returns only A and B rows, and the cap is the +/// top N of the CHOSEN databases (the newest row is C's, so a cap taken before the filter would be spent on it). The one-name +/// consumers on the empty and miss paths say "for the chosen databases" instead of giving a verdict about a database the read +/// never looked at. +/// +[Collection("live-postgres")] +public sealed class BlockingDatabaseFilterLiveTests +{ + private const string ServerName = "darling-blocking-db-filter-e2e"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + + private const string SkipReason = + "Set DARLING_TEST_PG to a Postgres connection string to run the live blocking database-filter test (it mints its own scratch database)."; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task BlockedProcessReportsReader_TwoNames_ReturnsOnlyThoseDatabases_OnBothArms_AndCapsAfterTheFilter() + { + await RunAsync(async (postgres, now, ct) => + { + var start = now.AddHours(-1); + + var all = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync(postgres, ServerId, start, now, 50, DatabaseFilter.All, ct); + Assert.Equal(new[] { "A", "B", "C", "D" }, all.Select(r => r.DatabaseName).Distinct().OrderBy(n => n, StringComparer.Ordinal).ToArray()); + Assert.Equal(7, all.Count); + + var chosen = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync(postgres, ServerId, start, now, 50, DatabaseFilter.Of(["A", "B"]), ct); + Assert.Equal(new[] { "A", "B" }, chosen.Select(r => r.DatabaseName).Distinct().OrderBy(n => n, StringComparer.Ordinal).ToArray()); + /* One XE row and one DMV row per chosen database: both arms were filtered. */ + Assert.Equal(4, chosen.Count); + Assert.Equal(2, chosen.Count(r => r.Source != BlockedProcessAlertRow.DmvSnapshotSource)); + + /* The overload's one-name form is the same predicate. */ + var one = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync(postgres, ServerId, start, now, 50, DatabaseFilter.One("B"), ct); + Assert.All(one, r => Assert.Equal("B", r.DatabaseName)); + Assert.Equal(2, one.Count); + + /* The cap runs over the chosen databases: C holds the newest XE row, so the unfiltered top row is C's. */ + var topAll = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync(postgres, ServerId, start, now, 1, DatabaseFilter.All, ct); + Assert.Equal("C", topAll.Single().DatabaseName); + var topChosen = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync(postgres, ServerId, start, now, 1, DatabaseFilter.Of(["A", "B"]), ct); + Assert.Equal("A", topChosen.Single().DatabaseName); + + /* The public-shaped overload (no filter) is still every database. */ + var bare = await DarlingBlockingReader.GetRecentBlockedProcessReportsAsync(postgres, ServerId, start, now, 50, ct); + Assert.Equal(7, bare.Count); + }); + } + + [Fact] + public async Task BlockedProcessReportsWithXmlReader_TwoNames_ReturnsOnlyThoseDatabases_AndCapsAfterTheFilter() + { + await RunAsync(async (postgres, now, ct) => + { + var start = now.AddHours(-1); + + var all = await DarlingBlockingReader.GetRecentBlockedProcessReportsWithXmlAsync(postgres, ServerId, start, now, 50, DatabaseFilter.All, ct); + Assert.Equal(3, all.Count); + + var chosen = await DarlingBlockingReader.GetRecentBlockedProcessReportsWithXmlAsync(postgres, ServerId, start, now, 50, DatabaseFilter.Of(["A", "B"]), ct); + Assert.Equal(new[] { "A", "B" }, chosen.Select(r => r.DatabaseName).OrderBy(n => n, StringComparer.Ordinal).ToArray()); + + var top = await DarlingBlockingReader.GetRecentBlockedProcessReportsWithXmlAsync(postgres, ServerId, start, now, 1, DatabaseFilter.Of(["A", "B"]), ct); + Assert.Equal("A", top.Single().DatabaseName); + }); + } + + [Fact] + public async Task BlockingTrendReader_TwoNames_CountsOnlyThoseDatabases_OnBothArms() + { + await RunAsync(async (postgres, now, ct) => + { + var start = now.AddHours(-1); + + var all = await DarlingBlockingTrendReader.GetBlockingTrendAsync(postgres, ServerId, start, now, DatabaseFilter.All, ct); + /* XE has A, B and C: the DMV arm stays out because the XE source has rows. */ + Assert.Equal(3, all.Sum(p => p.Count)); + + var chosen = await DarlingBlockingTrendReader.GetBlockingTrendAsync(postgres, ServerId, start, now, DatabaseFilter.Of(["A", "B"]), ct); + Assert.Equal(2, chosen.Sum(p => p.Count)); + + /* D has DMV-captured blocking only. The fallback asks whether XE has rows for the CHOSEN databases, so D charts. */ + var dmvOnly = await DarlingBlockingTrendReader.GetBlockingTrendAsync(postgres, ServerId, start, now, DatabaseFilter.One("D"), ct); + Assert.Equal(1, dmvOnly.Sum(p => p.Count)); + + /* A set that includes a database with XE rows keeps the XE arm alone, as an unfiltered read does. */ + var mixed = await DarlingBlockingTrendReader.GetBlockingTrendAsync(postgres, ServerId, start, now, DatabaseFilter.Of(["A", "D"]), ct); + Assert.Equal(1, mixed.Sum(p => p.Count)); + + var none = await DarlingBlockingTrendReader.GetBlockingTrendAsync(postgres, ServerId, start, now, DatabaseFilter.One("Nope"), ct); + Assert.Empty(none); + + var bare = await DarlingBlockingTrendReader.GetBlockingTrendAsync(postgres, ServerId, start, now, ct); + Assert.Equal(3, bare.Sum(p => p.Count)); + }); + } + + [Fact] + public async Task GetBlocking_TwoNames_ReturnsOnlyThoseDatabases_AndTheEmptyAndMissAnswersNameTheChosenDatabases() + { + await RunAsync(async (postgres, now, ct) => + { + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 50, null, false, null, DatabaseFilter.Of(["A", "B"]), 150, cancellationToken: ct))) + { + var events = doc.RootElement.GetProperty("events").EnumerateArray().ToList(); + Assert.Equal(new[] { "A", "B" }, events.Select(e => e.GetProperty("database_name").GetString()!).Distinct().OrderBy(n => n, StringComparer.Ordinal).ToArray()); + Assert.Equal(4, events.Count); + Assert.False(doc.RootElement.GetProperty("truncated").GetBoolean()); + /* #5244 L3: the page says it was filtered, in the words the other filtered tools echo. */ + Assert.Equal("the chosen databases", doc.RootElement.GetProperty("database_name").GetString()); + } + + /* The page is the newest `limit` of the chosen databases: limit 2 of A, B leaves the other two out and says so. */ + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 2, null, false, null, DatabaseFilter.Of(["A", "B"]), 150, cancellationToken: ct))) + { + Assert.Equal(2, doc.RootElement.GetProperty("events").GetArrayLength()); + Assert.True(doc.RootElement.GetProperty("truncated").GetBoolean()); + Assert.DoesNotContain(doc.RootElement.GetProperty("events").EnumerateArray(), e => e.GetProperty("database_name").GetString() == "C"); + } + + /* Empty: the status word stays, and the sentence is about the chosen databases only. */ + var emptyOne = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 50, null, false, null, DatabaseFilter.One("Nope"), 150, cancellationToken: ct)).RootElement; + Assert.Equal("empty", emptyOne.GetProperty("status").GetString()); + Assert.Contains("for the database Nope", emptyOne.GetProperty("message").GetString(), StringComparison.Ordinal); + + var emptyMany = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 50, null, false, null, DatabaseFilter.Of(["Nope", "Gone"]), 150, cancellationToken: ct)).RootElement; + Assert.Equal("empty", emptyMany.GetProperty("status").GetString()); + Assert.Contains("for the chosen databases", emptyMany.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.DoesNotContain("Nope", emptyMany.GetProperty("message").GetString(), StringComparison.Ordinal); + + /* Unfiltered, the sentence is the one it always was. */ + var emptyAll = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 50, null, false, now.AddDays(-30).ToString("o"), DatabaseFilter.All, 150, cancellationToken: ct)).RootElement; + Assert.Equal("empty", emptyAll.GetProperty("status").GetString()); + Assert.Equal("No blocking events found in the specified time range.", emptyAll.GetProperty("message").GetString()); + + /* A dedup_key from A's incident, asked over B alone: a miss that says the scan was limited to the chosen databases. */ + string aKey; + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 50, null, false, null, DatabaseFilter.One("A"), 150, cancellationToken: ct))) + { + aKey = doc.RootElement.GetProperty("events").EnumerateArray().First().GetProperty("dedup_key").GetString()!; + } + + using (var hit = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 50, aKey, false, null, DatabaseFilter.One("A"), 150, cancellationToken: ct))) + { + Assert.True(hit.RootElement.GetProperty("events").GetArrayLength() >= 1); + } + + var miss = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking( + postgres, ServerName, 1, 50, aKey, false, null, DatabaseFilter.One("B"), 150, cancellationToken: ct)).RootElement; + Assert.Equal("empty", miss.GetProperty("status").GetString()); + Assert.Contains("The scan was limited to the events for the database B", miss.GetProperty("message").GetString(), StringComparison.Ordinal); + }); + } + + [Fact] + public async Task GetBlockedProcessXml_TwoNames_ReturnsOnlyThoseDatabases_AndTheEmptyAnswerNamesTheChosenDatabases() + { + await RunAsync(async (postgres, now, ct) => + { + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockedProcessXml( + postgres, ServerName, 1, 50, null, DatabaseFilter.Of(["A", "B"]), cancellationToken: ct))) + { + var reports = doc.RootElement.GetProperty("reports").EnumerateArray().ToList(); + Assert.Equal(new[] { "A", "B" }, reports.Select(e => e.GetProperty("database_name").GetString()!).OrderBy(n => n, StringComparer.Ordinal).ToArray()); + Assert.Equal("the chosen databases", doc.RootElement.GetProperty("database_name").GetString()); + } + + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockedProcessXml( + postgres, ServerName, 1, 1, null, DatabaseFilter.Of(["A", "B"]), cancellationToken: ct))) + { + Assert.Equal("A", doc.RootElement.GetProperty("reports")[0].GetProperty("database_name").GetString()); + Assert.True(doc.RootElement.GetProperty("truncated").GetBoolean()); + } + + var empty = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockedProcessXml( + postgres, ServerName, 1, 50, null, DatabaseFilter.Of(["Nope", "Gone"]), cancellationToken: ct)).RootElement; + Assert.Equal("empty", empty.GetProperty("status").GetString()); + Assert.Contains("for the chosen databases", empty.GetProperty("message").GetString(), StringComparison.Ordinal); + + var emptyOne = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockedProcessXml( + postgres, ServerName, 1, 50, null, DatabaseFilter.One("Nope"), cancellationToken: ct)).RootElement; + Assert.Contains("for the database Nope", emptyOne.GetProperty("message").GetString(), StringComparison.Ordinal); + + var emptyAll = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockedProcessXml( + postgres, ServerName, 1, 50, now.AddDays(-30).ToString("o"), DatabaseFilter.All, cancellationToken: ct)).RootElement; + Assert.Equal("No blocked process report XML available in the specified time range.", emptyAll.GetProperty("message").GetString()); + }); + } + + [Fact] + public async Task GetBlockingTrend_TwoNames_CountsOnlyThoseDatabases_AndTheEmptyAnswerNamesTheChosenDatabases() + { + await RunAsync(async (postgres, now, ct) => + { + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockingTrend( + postgres, ServerName, 1, null, DatabaseFilter.Of(["A", "B"]), ct))) + { + Assert.Equal(2, doc.RootElement.GetProperty("trend").EnumerateArray().Sum(p => p.GetProperty("count").GetInt32())); + Assert.Equal("the chosen databases", doc.RootElement.GetProperty("database_name").GetString()); + /* N1: the exact tag, not just "some tag": a tool that hard-codes one source fails here. XE has rows for A and B. */ + Assert.Equal("blocked-process-report", doc.RootElement.GetProperty("source").GetString()); + } + + /* The collectors ran (seeded below) and none of the chosen databases' blocking was in the captures: an all-clear + for THOSE databases, worded that way, with the status word unchanged. */ + var one = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockingTrend( + postgres, ServerName, 1, null, DatabaseFilter.One("Nope"), ct)).RootElement; + Assert.Equal("empty", one.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Null, one.GetProperty("source").ValueKind); + Assert.Contains("for the database Nope in the last 1 hour(s)", one.GetProperty("message").GetString(), StringComparison.Ordinal); + + var many = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockingTrend( + postgres, ServerName, 1, null, DatabaseFilter.Of(["Nope", "Gone"]), ct)).RootElement; + Assert.Equal("empty", many.GetProperty("status").GetString()); + Assert.Contains("for the chosen databases in the last 1 hour(s)", many.GetProperty("message").GetString(), StringComparison.Ordinal); + + /* A window the collectors never ran in stays "unavailable" whatever the selection (no capture is no all-clear). */ + var gap = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlockingTrend( + postgres, ServerName, 1, now.AddDays(-20).ToString("o"), DatabaseFilter.One("Nope"), ct)).RootElement; + Assert.Equal("unavailable", gap.GetProperty("status").GetString()); + }); + } + + /// The database predicate is the list form on every statement the three reads route to (no live store needed): the XE reads at + /// $6 (after the window floor at $5), the DMV fallback at $5, and BOTH arms of the trend at $5 (after the floor at $4), and + /// none of them is spliced with a name. + [Fact] + public void EveryBlockingStatement_CarriesTheListPredicate_OnEveryArm() + { + var xe = DatabaseFilter.All.Clause("database_name", 6); + Assert.Contains(xe, DarlingBlockingReader.BlockedProcessReportsSql, StringComparison.Ordinal); + Assert.Contains(xe, DarlingBlockingReader.BlockedProcessReportsWithXmlSql, StringComparison.Ordinal); + Assert.Contains(DatabaseFilter.All.Clause("database_name", 5), DarlingBlockingReader.DmvBlockingSnapshotsSql, StringComparison.Ordinal); + + var trend = DatabaseFilter.All.Clause("database_name", 5); + var first = DarlingBlockingTrendReader.BlockingTrendSql.IndexOf(trend, StringComparison.Ordinal); + Assert.True(first >= 0, "the XE arm of the trend"); + Assert.True(DarlingBlockingTrendReader.BlockingTrendSql.IndexOf(trend, first + trend.Length, StringComparison.Ordinal) > first, "the DMV arm of the trend"); + } + + /// Seeds A, B and C, then runs the body. XE rows (all with a report XML), newest first: C at -5 min, A at -10, B at -11. + /// DMV rows, with spid pairs no XE row covers: one each for A, B and C, and a D whose only blocking is DMV-captured. + private static async Task RunAsync(Func body) + { + var baseConnectionString = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), SkipReason); + var ct = TestContext.Current.CancellationToken; + + // #1776 own-store: a scratch database, so no other class's chunks shape the store under test (#4650). + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var cs = scratch.ConnectionString; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PrepareStoreAsync(cs, connection, ct); + await DeleteRowsAsync(connection, ct); + await using var postgres = NpgsqlDataSource.Create(cs); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow); + + var xe = new (string Database, int Minutes, int Spid)[] { ("C", 5, 103), ("A", 10, 101), ("B", 11, 102) }; + foreach (var (database, minutes, spid) in xe) + { + var eventTime = DarlingMcpTestData.Naive(now.AddMinutes(-minutes)); + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocked_ecid, blocking_spid, blocking_ecid, wait_time_ms, lock_mode, + blocked_sql_text, blocking_sql_text, blocked_process_report_xml) +VALUES ($1,$2,$3,$4,$5,$6,$7,0,$8,0,1000,'X','SELECT 1','UPDATE t SET c = 1','')", + CollectionIdGenerator.Next(), eventTime.AddSeconds(30), ServerId, ServerName, eventTime, database, spid, 900 + spid); + } + + var dmv = new (string Database, int Minutes, int Spid)[] { ("C", 20, 303), ("A", 21, 301), ("B", 22, 302), ("D", 23, 304) }; + foreach (var (database, minutes, spid) in dmv) + { + var at = DarlingMcpTestData.Naive(now.AddMinutes(-minutes)); + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO dmv_blocking_snapshots + (collection_id, collection_time, server_id, server_name, event_time, database_name, blocked_spid, blocking_spid, wait_time_ms, blocking_status) +VALUES ($1,$2,$3,$4,$2,$5,$6,60,12000,'suspended')", + CollectionIdGenerator.Next(), at, ServerId, ServerName, database, spid); + } + + /* Collector runs that looked at the last hour, so an empty trend is an all-clear and not a gap. */ + foreach (var collector in new[] { "blocked_process_report", "dmv_blocking_snapshot" }) + { + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO collection_log + (log_id, server_id, server_name, collector_name, collection_time, + duration_ms, status, error_message, rows_collected, sql_duration_ms, duckdb_duration_ms) +VALUES ($1, $2, $3, $4, $5, 100, 'SUCCESS', NULL, 0, 80, 20)", + CollectionIdGenerator.Next(), ServerId, ServerName, collector, DarlingMcpTestData.Naive(now.AddMinutes(-30))); + } + + await body(postgres, now, ct); + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + private static async Task PrepareStoreAsync(string connectionString, NpgsqlConnection connection, CancellationToken ct) + { + await PgMigrations.MigrateAsync(connection, ct); + if (await LiveTimescaleProbe.TryEnableAsync(connectionString, ct)) + { + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + await using var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection); + await stop.ExecuteNonQueryAsync(ct); + } + } + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM blocked_process_reports WHERE server_id = {ServerId}; DELETE FROM dmv_blocking_snapshots WHERE server_id = {ServerId}; DELETE FROM collection_log WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};", + connection); + await cleanup.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/BlockingListPlanFlagsLivePostgresTests.cs b/Darling/Darling.Tests/BlockingListPlanFlagsLivePostgresTests.cs new file mode 100644 index 0000000000..af9373826c --- /dev/null +++ b/Darling/Darling.Tests/BlockingListPlanFlagsLivePostgresTests.cs @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5236: the plan-presence flags on get_blocking and get_deadlocks come from one keyed statement over the PAGE's +/// rows, not from the list read, so a compressed chunk decompresses a plan column only for the batches holding the page's own +/// rows. The list statements must stay free of every plan column (their planner output names none), and the flags must still be +/// right for a page whose rows carry a plan on both sides, one side, an empty string, or NULL, and must not leak onto rows +/// past the page. +/// +[Collection("live-postgres")] +public sealed class BlockingListPlanFlagsLivePostgresTests +{ + private const string ServerName = "darling-list-plan-flags-e2e"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + + private const string Plan = ""; + + private const string SkipReason = + "Set DARLING_TEST_PG to a Postgres connection string to run the live list plan flag test (it mints its own scratch database)."; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task GetBlocking_FlagsThePagesRows_ByTheirOwnStoredPlans() + { + var baseConnectionString = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), SkipReason); + var ct = TestContext.Current.CancellationToken; + + // #1776 own-store: a scratch database, so no other class's chunks shape the store under test (#4650). + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var cs = scratch.ConnectionString; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PrepareStoreAsync(cs, connection, ct); + await DeleteRowsAsync(connection, ct); + await using var postgres = NpgsqlDataSource.Create(cs); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + /* The tool reads its "last N hours" window from the real clock, which the test clock override cannot move (#5608). */ + var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow); + + /* Newest first by event_time: spid 101 (both plans), 102 (blocked only), 103 (blocking only), 104 (empty strings), + 105 (NULLs), 106 (both plans; the oldest, so a page of five leaves it out). */ + var seeded = new (int Spid, string? Blocked, string? Blocking)[] + { + (101, Plan, Plan), (102, Plan, null), (103, null, Plan), (104, "", ""), (105, null, null), (106, Plan, Plan), + }; + for (var i = 0; i < seeded.Length; i++) + { + var eventTime = now.AddMinutes(-10 - i); + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocked_ecid, blocking_spid, blocking_ecid, wait_time_ms, lock_mode, + blocked_sql_text, blocking_sql_text, blocked_process_report_xml, blocked_query_plan_xml, blocking_query_plan_xml) +VALUES ($1,$2,$3,$4,$5,'PlanFlagsDb',$6,0,$7,0,1000,'X','SELECT 1','UPDATE t SET c = 1','',$8,$9)", + CollectionIdGenerator.Next(), eventTime.AddMinutes(1), ServerId, ServerName, eventTime, + seeded[i].Spid, 900 + i, (object?)seeded[i].Blocked ?? DBNull.Value, (object?)seeded[i].Blocking ?? DBNull.Value); + } + + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName, limit: 5))) + { + var events = doc.RootElement.GetProperty("events").EnumerateArray().ToList(); + Assert.Equal(new[] { 101, 102, 103, 104, 105 }, events.Select(e => e.GetProperty("blocked_spid").GetInt32()).ToArray()); + Assert.True(doc.RootElement.GetProperty("truncated").GetBoolean()); + + /* Written only when true: a row with no plan carries neither key. */ + Assert.Equal(new[] { (true, true), (true, false), (false, true), (false, false), (false, false) }, + events.Select(e => (Flag(e, "has_blocked_plan"), Flag(e, "has_blocking_plan"))).ToArray()); + Assert.False(events[3].TryGetProperty("has_blocked_plan", out _)); + Assert.False(events[4].TryGetProperty("has_blocking_plan", out _)); + } + + /* A page of two: the flags are for those two rows, and the rest of the window is not read for them. */ + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetBlocking(postgres, ServerName, limit: 2))) + { + var events = doc.RootElement.GetProperty("events").EnumerateArray().ToList(); + Assert.Equal(2, events.Count); + Assert.Equal(new[] { (true, true), (true, false) }, + events.Select(e => (Flag(e, "has_blocked_plan"), Flag(e, "has_blocking_plan"))).ToArray()); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + [Fact] + public async Task GetDeadlocks_FlagsThePagesRows_ByTheirOwnStoredPlans() + { + var baseConnectionString = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), SkipReason); + var ct = TestContext.Current.CancellationToken; + + // #1776 own-store: a scratch database, so no other class's chunks shape the store under test (#4650). + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var cs = scratch.ConnectionString; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PrepareStoreAsync(cs, connection, ct); + await DeleteRowsAsync(connection, ct); + await using var postgres = NpgsqlDataSource.Create(cs); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + /* The tool reads its "last N hours" window from the real clock, which the test clock override cannot move (#5608). */ + var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow); + + /* Newest first by deadlock_time: a plan, an empty string, NULL, and an older plan the page of three leaves out. */ + var seeded = new (string Victim, string? Plan)[] { ("processA", Plan), ("processB", ""), ("processC", null), ("processD", Plan) }; + for (var i = 0; i < seeded.Length; i++) + { + var deadlockTime = now.AddMinutes(-10 - i); + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO deadlocks + (deadlock_id, collection_time, server_id, server_name, deadlock_time, victim_process_id, victim_sql_text, deadlock_graph_xml, victim_query_plan_xml, database_name) +VALUES ($1,$2,$3,$4,$5,$6,'UPDATE t SET c = 1','',$7,'PlanFlagsDb')", + CollectionIdGenerator.Next(), deadlockTime.AddMinutes(1), ServerId, ServerName, deadlockTime, + seeded[i].Victim, (object?)seeded[i].Plan ?? DBNull.Value); + } + + using (var doc = JsonDocument.Parse(await DarlingMcpBlockingTools.GetDeadlocks(postgres, ServerName, limit: 3))) + { + var rows = doc.RootElement.GetProperty("deadlocks").EnumerateArray().ToList(); + Assert.Equal(new[] { "processA", "processB", "processC" }, rows.Select(r => r.GetProperty("victim_process_id").GetString()).ToArray()); + Assert.Equal(new[] { true, false, false }, rows.Select(r => Flag(r, "has_victim_plan")).ToArray()); + Assert.False(rows[1].TryGetProperty("has_victim_plan", out _)); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs, bodySucceeded, async (cleanup, cleanupCt) => + await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + /// The planner's own output for each list statement names no plan column, on the store as it is built here (a + /// hypertable where TimescaleDB is present). A flag computed in the list is what put the plan text through its sorts. + [Fact] + public async Task TheListStatements_PlannerOutput_NamesNoPlanColumn() + { + var baseConnectionString = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), SkipReason); + var ct = TestContext.Current.CancellationToken; + + // #1776 own-store: a scratch database, so no other class's chunks shape the plan under test (#4650). + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var cs = scratch.ConnectionString; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PrepareStoreAsync(cs, connection, ct); + + var now = LiveClock.Now(); + foreach (var sql in new[] + { + DarlingBlockingReader.BlockedProcessReportsSql, + DarlingBlockingReader.BlockedProcessReportsWithXmlSql, + DarlingBlockingReader.RecentDeadlocksSql, + DarlingBlockingReader.RecentDeadlocksWithGraphSql, + }) + { + using var explain = new NpgsqlCommand("EXPLAIN (VERBOSE, COSTS OFF) " + sql, connection); + DarlingMcpReadParameters.AddWindow(explain, ServerId, now.AddHours(-24), now); + DarlingMcpReadParameters.AddInt(explain, 21); + DarlingMcpReadParameters.AddTimestamp(explain, EventWindowFloor.For(now.AddHours(-24))); + /* #5244: the blocked-process reads carry the database filter at $6 (the deadlock reads have none). */ + if (sql.Contains("$6", StringComparison.Ordinal)) + explain.Parameters.Add(DatabaseFilter.All.Parameter()); + var lines = new List(); + await using (var reader = await explain.ExecuteReaderAsync(ct)) + { + while (await reader.ReadAsync(ct)) + { + lines.Add(reader.GetString(0)); + } + } + + Assert.DoesNotContain(lines, line => line.Contains("query_plan_xml", StringComparison.Ordinal)); + } + } + + /// The store the way the service builds it: migrated, with hypertables where TimescaleDB is present (the probe + /// runs on its own connection, #1922), and no background policy job reshaping chunks under the assertions. + private static async Task PrepareStoreAsync(string connectionString, NpgsqlConnection connection, System.Threading.CancellationToken ct) + { + await PgMigrations.MigrateAsync(connection, ct); + if (await LiveTimescaleProbe.TryEnableAsync(connectionString, ct)) + { + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + await using var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection); + await stop.ExecuteNonQueryAsync(ct); + } + } + + private static bool Flag(JsonElement row, string name) => + row.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) + { + using var cleanup = new NpgsqlCommand( + $"DELETE FROM blocked_process_reports WHERE server_id = {ServerId}; DELETE FROM deadlocks WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};", + connection); + await cleanup.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/BlockingThresholdCoverageTests.cs b/Darling/Darling.Tests/BlockingThresholdCoverageTests.cs new file mode 100644 index 0000000000..015d19127d --- /dev/null +++ b/Darling/Darling.Tests/BlockingThresholdCoverageTests.cs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Common; +using Xunit; + +namespace Darling.Tests; + +/// The shared rule that moves the Blocking charts' data start to where the blocked process threshold went on (#5098). +public sealed class BlockingThresholdCoverageTests +{ + private static readonly DateTime End = new(2026, 10, 1, 12, 0, 0, DateTimeKind.Utc); + private static readonly DateTime Start = End.AddDays(-7); + + [Fact] + public void ThresholdOnAtTheStart_KeepsTheCollectorsCoverage() + { + Assert.Equal(Start, BlockingThresholdCoverage.Combine(Start, End.AddDays(-1), onAtWindowStart: true, End.AddDays(-4), sawZeroSnapshot: true)); + } + + [Fact] + public void ThresholdOnAtTheStart_StillTakesAnEarlierReport() + { + var report = End.AddDays(-8); + Assert.Equal(report, BlockingThresholdCoverage.Combine(Start, report, true, null, false)); + } + + [Fact] + public void FirstNonzeroSnapshotInTheWindow_LaterThanCoverage_IsTheAnswer() + { + Assert.Equal(End.AddDays(-4), BlockingThresholdCoverage.Combine(Start, End.AddDays(-3), false, End.AddDays(-4), true)); + } + + [Fact] + public void AReportBeforeTheFirstNonzeroSnapshot_IsTheAnswer() + { + var report = End.AddDays(-4).AddHours(-12); + Assert.Equal(report, BlockingThresholdCoverage.Combine(Start, report, false, End.AddDays(-4), true)); + } + + [Fact] + public void CoverageLaterThanTheSnapshot_StaysTheCoverage() + { + var coverage = End.AddDays(-2); + Assert.Equal(coverage, BlockingThresholdCoverage.Combine(coverage, End.AddDays(-1), false, End.AddDays(-4), true)); + } + + [Fact] + public void OnlyZeroSnapshots_StartAtTheEarliestReport() + { + var report = End.AddDays(-1); + Assert.Equal(report, BlockingThresholdCoverage.Combine(Start, report, false, null, true)); + } + + [Fact] + public void NoThresholdSnapshots_KeepTodaysAnswer() + { + Assert.Equal(Start, BlockingThresholdCoverage.Combine(Start, End.AddDays(-1), false, null, false)); + Assert.Equal(End.AddDays(-1), BlockingThresholdCoverage.Combine(null, End.AddDays(-1), false, null, false)); + } + + [Fact] + public void NonzeroSnapshotsInTheWindow_WithNoZeroSeen_LeaveTodaysAnswer() + { + /* The snapshots before the window may have been purged, or never taken: no zero seen means the threshold's start is unknown, not late. */ + Assert.Equal(Start, BlockingThresholdCoverage.Combine(Start, End.AddDays(-1), false, End.AddDays(-4), sawZeroSnapshot: false)); + Assert.Equal(End.AddDays(-1), BlockingThresholdCoverage.Combine(null, End.AddDays(-1), false, End.AddDays(-4), false)); + } + + [Fact] + public void NothingKnown_IsNull() + { + Assert.Null(BlockingThresholdCoverage.Combine(null, null, false, null, false)); + Assert.Null(BlockingThresholdCoverage.Combine(null, null, true, null, true)); + } + + [Fact] + public void NoCollectorCoverage_TakesTheThresholdStart_ButNeverLaterThanTheReport() + { + Assert.Equal(End.AddDays(-4), BlockingThresholdCoverage.Combine(null, End.AddDays(-3), false, End.AddDays(-4), true)); + Assert.Equal(End.AddDays(-5), BlockingThresholdCoverage.Combine(null, End.AddDays(-5), false, End.AddDays(-4), true)); + } +} diff --git a/Darling/Darling.Tests/BlockingWaitsFilteredEmptyWordingTests.cs b/Darling/Darling.Tests/BlockingWaitsFilteredEmptyWordingTests.cs new file mode 100644 index 0000000000..b7a0d1ba61 --- /dev/null +++ b/Darling/Darling.Tests/BlockingWaitsFilteredEmptyWordingTests.cs @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace PerformanceMonitor.Darling.Tests; + +/// +/// #5244 PR3 (W3 wording carry): a filtered empty answer reads the same in Darling and Lite. These are the words Lite's +/// McpDatabaseSelectionTests pins with the same literals: the name for one database, "the chosen databases" for two or more. +/// +public sealed class BlockingWaitsFilteredEmptyWordingTests +{ + [Fact] + public void Describe_IsTheNameForOne_AndTheChosenDatabasesForTwoOrMore_AndNothingForAll() + { + Assert.Null(DatabaseFilter.All.Describe()); + Assert.Equal("A", DatabaseFilter.One("A").Describe()); + Assert.Equal("the chosen databases", DatabaseFilter.Of(["A", "B"]).Describe()); + Assert.Equal("the chosen databases", DatabaseFilter.ManyDatabasesDescription); + } + + [Fact] + public void TheEmptyAnswerSentence_SaysTheDatabaseForOne_AndTheChosenDatabasesForTwoOrMore() + { + Assert.Equal(string.Empty, DarlingMcpBlockingTools.ForChosenDatabases(DatabaseFilter.All)); + Assert.Equal(" for the database A", DarlingMcpBlockingTools.ForChosenDatabases(DatabaseFilter.One("A"))); + Assert.Equal(" for the chosen databases", DarlingMcpBlockingTools.ForChosenDatabases(DatabaseFilter.Of(["A", "B"]))); + } +} diff --git a/Darling/Darling.Tests/BlockingWaitsWebDispatchLiveTests.cs b/Darling/Darling.Tests/BlockingWaitsWebDispatchLiveTests.cs new file mode 100644 index 0000000000..c7966bf7c1 --- /dev/null +++ b/Darling/Darling.Tests/BlockingWaitsWebDispatchLiveTests.cs @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Darling.Tests; +using Microsoft.AspNetCore.Http; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace PerformanceMonitor.Darling.Tests; + +/// +/// #5244 PR3 (lane W3): the six blocking and waits reads the Blocking and Current Waits panels drive, called the way the page calls +/// them, through the web read dispatch with two REPEATED database_name keys over a seed of databases A, B and C. Each read +/// must answer with A's and B's rows and none of C's. On the base the dispatch ignored the key, so each read returned all three. +/// +[Collection("live-postgres")] +public sealed class BlockingWaitsWebDispatchLiveTests +{ + private const string ServerName = "darling-w3-blocking-waits-web"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + + private static readonly string[] Reads = + [ + "get_waiting_tasks", "get_blocking", "get_blocked_process_xml", + "get_blocking_trend", "get_current_waits_trend", "get_blocking_stats", + ]; + + private static async Task WebReadAsync(NpgsqlDataSource postgres, string read, params string[] databases) + { + var query = new List> { new("server_name", ServerName), new("hours_back", "2") }; + query.AddRange(databases.Select(d => new KeyValuePair("database_name", d))); + var context = new DefaultHttpContext(); + context.Request.QueryString = QueryString.Create(query); + return await DarlingWebEndpoints.BuildReadDispatch()[read](context, postgres, null!); + } + + /// The databases (or, for the two count series, the total count) a read's answer covers. + private static (List Databases, long Count) CoverageOf(string read, string json) + { + var root = JsonDocument.Parse(json).RootElement; + List Databases(string property) + { + Assert.True(root.TryGetProperty(property, out var rows), $"{read}: not the success shape: {json}"); + return rows.EnumerateArray().Select(r => r.GetProperty("database_name").GetString()!).Distinct().OrderBy(n => n, StringComparer.Ordinal).ToList(); + } + long Total(string property, string field) + { + Assert.True(root.TryGetProperty(property, out var rows), $"{read}: not the success shape: {json}"); + return rows.EnumerateArray().Sum(p => p.GetProperty(field).GetInt64()); + } + return read switch + { + "get_waiting_tasks" => (Databases("tasks"), 0), + "get_blocking" => (Databases("events"), 0), + "get_blocked_process_xml" => (Databases("reports"), 0), + "get_current_waits_trend" => (Databases("blocked_sessions"), 0), + "get_blocking_trend" => ([], Total("trend", "count")), + _ => ([], Total("blocking_duration", "event_count")), + }; + } + + [Fact] + public async Task EachOfTheSixReads_WithTwoChosenDatabases_ReturnsOnlyThoseDatabasesRows() + { + var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live blocking and waits web dispatch tests."); + var ct = TestContext.Current.CancellationToken; + + // #1776 own-store: a scratch database, so no other class's chunks shape the store under test (#4650). + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + var cs = scratch.ConnectionString; + using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + if (await LiveTimescaleProbe.TryEnableAsync(cs, ct)) + { + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + await using var stop = new NpgsqlCommand("SELECT _timescaledb_functions.stop_background_workers()", connection); + await stop.ExecuteNonQueryAsync(ct); + } + + await using var postgres = NpgsqlDataSource.Create(cs); + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var now = DarlingMcpTestData.TruncateToSeconds(DateTime.UtcNow); + + var spid = 100; + foreach (var db in new[] { "A", "B", "C" }) + { + spid++; + var eventTime = DarlingMcpTestData.Naive(now.AddMinutes(-10)); + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO blocked_process_reports + (blocked_report_id, collection_time, server_id, server_name, event_time, database_name, + blocked_spid, blocked_ecid, blocking_spid, blocking_ecid, wait_time_ms, lock_mode, + blocked_sql_text, blocking_sql_text, blocked_process_report_xml) +VALUES ($1,$2,$3,$4,$5,$6,$7,0,$8,0,1000,'X','SELECT 1','UPDATE t SET c = 1','')", + CollectionIdGenerator.Next(), eventTime.AddSeconds(30), ServerId, ServerName, eventTime, db, spid, 900 + spid); + + /* A blocked waiting task per database, so the blocked-session series and the waiting-task list both have a row each. */ + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO waiting_tasks (collection_id, collection_time, server_id, server_name, session_id, wait_type, wait_duration_ms, blocking_session_id, resource_description, database_name) +VALUES ($1,$2,$3,$4,$5,'LCK_M_S',3000,90,NULL,$6)", + CollectionIdGenerator.Next(), DarlingMcpTestData.Naive(now.AddMinutes(-10)), ServerId, ServerName, spid, db); + } + + /* The collectors ran, so a trend's empty answer is an all-clear and not a gap. */ + foreach (var collector in new[] { "blocked_process_report", "waiting_tasks" }) + { + await DarlingMcpTestData.ExecAsync(connection, ct, + @"INSERT INTO collection_log (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, rows_collected) +VALUES ($1,$2,$3,$4,$5,100,'SUCCESS',0)", + CollectionIdGenerator.Next(), ServerId, ServerName, collector, DarlingMcpTestData.Naive(now.AddMinutes(-30))); + } + + foreach (var read in Reads) + { + var all = CoverageOf(read, await WebReadAsync(postgres, read)); + var chosen = CoverageOf(read, await WebReadAsync(postgres, read, "A", "B")); + if (read is "get_blocking_trend" or "get_blocking_stats") + { + Assert.True(all.Count == 3, $"{read}: the control (no database_name) counted {all.Count}, not 3"); + Assert.True(chosen.Count == 2, $"{read} over A and B counted {chosen.Count}, not 2"); + } + else + { + Assert.True(new[] { "A", "B", "C" }.SequenceEqual(all.Databases), $"{read}: the control covered [{string.Join(",", all.Databases)}]"); + Assert.True(new[] { "A", "B" }.SequenceEqual(chosen.Databases), $"{read} over A and B covered [{string.Join(",", chosen.Databases)}]"); + } + + /* A blank-only list is refused, never widened to "all databases". */ + var refused = JsonDocument.Parse(await WebReadAsync(postgres, read, " ", "")).RootElement; + Assert.True(refused.GetProperty("status").GetString() == "invalid", $"{read}: a blank-only database_name must be refused"); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs, bodySucceeded, async (cleanup, cleanupCt) => + await DarlingMcpTestData.ExecAsync(cleanup, cleanupCt, + $"DELETE FROM waiting_tasks WHERE server_id = {ServerId}; DELETE FROM blocked_process_reports WHERE server_id = {ServerId}; DELETE FROM collection_log WHERE server_id = {ServerId}; DELETE FROM servers WHERE server_id = {ServerId};")); + } + } +} diff --git a/Darling/Darling.Tests/BoundedCompressionDdlTests.cs b/Darling/Darling.Tests/BoundedCompressionDdlTests.cs new file mode 100644 index 0000000000..62d904412f --- /dev/null +++ b/Darling/Darling.Tests/BoundedCompressionDdlTests.cs @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// The compression-settings ALTERs on the hourly pass wait for their AccessExclusiveLock at most +/// , so a long reader cannot queue every collector write +/// behind them (#4970). Source pins for the wiring, a live lock-holder pin for the behaviour, and the rule for a +/// failed settings read. +/// +/* #1776 own-store: each live fact mints its own scratch database through ScratchPostgres. */ +[Collection("timing")] +public sealed class BoundedCompressionDdlTests +{ + private static string Storage() => + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "TimescaleSupport.cs").Replace("\r\n", "\n"); + + private static string Body(string text, string signature) + { + var start = text.IndexOf(signature, StringComparison.Ordinal); + Assert.True(start >= 0, $"could not locate {signature}"); + var end = text.IndexOf("\n }\n", start, StringComparison.Ordinal); + Assert.True(end > start, $"could not find the end of {signature}"); + return text.Substring(start, end - start); + } + + [Fact] + public void TheHelper_RunsInsideATransaction_WithASetLocalLockTimeout_AndCatchesLockNotAvailable() + { + var helper = Body(Storage(), "internal static async Task TryRunBoundedDdlAsync("); + Assert.Contains("BeginTransactionAsync", helper, StringComparison.Ordinal); + Assert.Contains("SET LOCAL lock_timeout", helper, StringComparison.Ordinal); + Assert.Contains("PostgresErrorCodes.LockNotAvailable", helper, StringComparison.Ordinal); + Assert.Contains("CommitAsync", helper, StringComparison.Ordinal); + } + + [Fact] + public void BothCompressionAlters_GoThroughTheHelper_NotAnUnboundedCommand() + { + var text = Storage(); + var collector = Body(text, "public static async Task ApplyCompressionPolicyAsync("); + var aggregate = Body(text, "public static async Task EnsureAggregateCompressionAsync("); + + Assert.Contains("TryRunBoundedDdlAsync(", collector, StringComparison.Ordinal); + Assert.Contains("EnableCompressionSql(schema)", collector, StringComparison.Ordinal); + Assert.DoesNotContain("new NpgsqlCommand(EnableCompressionSql(schema)", collector, StringComparison.Ordinal); + + Assert.Contains("TryRunBoundedDdlAsync(", aggregate, StringComparison.Ordinal); + Assert.DoesNotContain("new NpgsqlCommand(EnableAggregateCompressionSql(view)", aggregate, StringComparison.Ordinal); + } + + [Fact] + public void TheHourlyPass_SkipsTheEnableAlters_AfterAFailedRead_AndTheStartPathDoesNot() + { + var text = Storage(); + var collector = Body(text, "public static async Task ApplyCompressionPolicyAsync("); + Assert.Contains("bool hourly, CancellationToken cancellationToken", collector, StringComparison.Ordinal); + Assert.Contains("var skipEnable = hourly && converged is null;", collector, StringComparison.Ordinal); + Assert.Contains("if (skipEnable)", collector, StringComparison.Ordinal); + + var worker = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs").Replace("\r\n", "\n"); + Assert.Contains("ApplyCompressionPolicyAsync(connection, logger, hourly: true, ct)", worker, StringComparison.Ordinal); + } + + [Fact] + public async Task AFailedSettingsRead_SkipsTheAltersOnTheHourlyPass_AndIssuesThemOnTheStartPath() + { + var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live failed-read pin."); + var ct = TestContext.Current.CancellationToken; + + /* A scratch database with no TimescaleDB extension: the settings read fails (no timescaledb_information + schema), which is the failed-read case without a mock. */ + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + + await ExecAsync(connection, "DROP EXTENSION IF EXISTS timescaledb CASCADE", ct); + + var hourlyLog = new CapturingTestLogger(); + await TimescaleSupport.ApplyCompressionPolicyAsync(connection, hourlyLog, hourly: true, ct); + Assert.Equal(1, hourlyLog.Lines.Count(l => l.Contains("skipping the compression-enable ALTERs", StringComparison.Ordinal))); + Assert.DoesNotContain(hourlyLog.Lines, l => l.Contains("compression settings on collect.", StringComparison.Ordinal)); + + var startLog = new CapturingTestLogger(); + await TimescaleSupport.ApplyCompressionPolicyAsync(connection, startLog, ct); + Assert.DoesNotContain(startLog.Lines, l => l.Contains("skipping the compression-enable ALTERs", StringComparison.Ordinal)); + Assert.Contains(startLog.Lines, l => l.Contains("compression settings on collect.", StringComparison.Ordinal)); + } + + [Fact] + public async Task ALongReader_CannotStallTheHourlyEnableAlter_OrTheWritesBehindIt_AndTheNextPassConverges() + { + var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live lock-holder pin."); + var ct = TestContext.Current.CancellationToken; + const string Table = "wait_stats"; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + Assert.True(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct), "TimescaleDB must be available"); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + + Assert.DoesNotContain(Table, await TimescaleSupport.ReadTablesNeedingCompressionEnableAsync(connection, null, ct) ?? throw new InvalidOperationException("read failed")); + + using var holder = new NpgsqlConnection(scratch.ConnectionString); + await holder.OpenAsync(ct); + using var writer = new NpgsqlConnection(scratch.ConnectionString); + await writer.OpenAsync(ct); + + var log = new CapturingTestLogger(); + var holding = true; + try + { + await ExecAsync(holder, "BEGIN", ct); + await ExecAsync(holder, $"SELECT 1 FROM collect.{Table} LIMIT 1", ct); + + var clock = Stopwatch.StartNew(); + var pass = Task.Run(() => TimescaleSupport.ApplyCompressionPolicyAsync(connection, log, hourly: true, ct), ct); + + /* The INSERT is timed while the ALTER is still queued for its lock. */ + using var observer = new NpgsqlConnection(scratch.ConnectionString); + await observer.OpenAsync(ct); + var waiting = false; + var pollClock = Stopwatch.StartNew(); + while (!waiting && pollClock.Elapsed < TimeSpan.FromSeconds(5)) + { + using var probe = new NpgsqlCommand( + "SELECT count(*) FROM pg_locks l JOIN pg_class c ON c.oid = l.relation JOIN pg_namespace n ON n.oid = c.relnamespace " + + "WHERE l.mode = 'AccessExclusiveLock' AND NOT l.granted AND n.nspname = 'collect' AND c.relname = @t", observer); + probe.Parameters.AddWithValue("t", Table); + waiting = Convert.ToInt64(await probe.ExecuteScalarAsync(ct), System.Globalization.CultureInfo.InvariantCulture) > 0; + if (!waiting) + { + await Task.Delay(50, ct); + } + } + + Assert.True(waiting, $"the ALTER on collect.{Table} never showed an ungranted AccessExclusiveLock request within 5 s"); + var lockTimeout = TimeSpan.FromSeconds(int.Parse(TimescaleSupport.HourlyDdlLockTimeout.TrimEnd('s'), System.Globalization.CultureInfo.InvariantCulture)); + var insertClock = Stopwatch.StartNew(); + await ExecAsync(writer, + $"INSERT INTO collect.{Table} (collection_id, collection_time, server_id, server_name, wait_type, delta_waiting_tasks, delta_wait_time_ms, sample_interval_seconds) VALUES (1, now(), 1, 's', 'W', 1, 1, 1)", ct); + Assert.True(insertClock.Elapsed < lockTimeout + TimeSpan.FromSeconds(1.5), $"the insert must not queue behind the waiting ALTER, took {insertClock.Elapsed}"); + + await pass; + Assert.True(clock.Elapsed < TimeSpan.FromSeconds(10), $"the pass must return in a bounded time, took {clock.Elapsed}"); + + Assert.DoesNotContain(Table, await TimescaleSupport.ReadTablesNeedingCompressionEnableAsync(connection, null, ct) ?? throw new InvalidOperationException("read failed")); + Assert.Equal(1, log.Lines.Count(l => l.Contains($"collect.{Table}", StringComparison.Ordinal) && l.Contains("not changed this pass", StringComparison.Ordinal))); + + await ExecAsync(holder, "ROLLBACK", ct); + holding = false; + await TimescaleSupport.ApplyCompressionPolicyAsync(connection, log, hourly: true, ct); + var after = await TimescaleSupport.ReadTablesNeedingCompressionEnableAsync(connection, null, ct) ?? throw new InvalidOperationException("read failed"); + Assert.Contains(Table, after); + } + finally + { + if (holding) + { + await ExecAsync(holder, "ROLLBACK", CancellationTokenNone()); + } + } + } + + private static System.Threading.CancellationToken CancellationTokenNone() => System.Threading.CancellationToken.None; + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, System.Threading.CancellationToken ct) + { + using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 60 }; + await command.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/BoundedDdlBusyStreakTests.cs b/Darling/Darling.Tests/BoundedDdlBusyStreakTests.cs new file mode 100644 index 0000000000..e1bcda6078 --- /dev/null +++ b/Darling/Darling.Tests/BoundedDdlBusyStreakTests.cs @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// A bounded change whose table stays busy pass after pass escalates once to a Warning and says so when it +/// finally converges (#4980). +/// +public sealed class BoundedDdlBusyStreakTests +{ + private static readonly DateTime T0 = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc); + + private static bool Busy(BoundedDdlBusyStreaks s, string key, int n = 1) + { + var escalated = false; + for (var i = 0; i < n; i++) + { + escalated |= s.RecordBusy(key, T0.AddHours(i), out _, out _); + } + + return escalated; + } + + [Fact] + public void TwoBusyPasses_DoNotEscalate() + { + var s = new BoundedDdlBusyStreaks(); + Assert.False(Busy(s, "a", 2)); + } + + [Fact] + public void TheThirdBusyPass_EscalatesOnce_AndOnlyOnce() + { + var s = new BoundedDdlBusyStreaks(); + Assert.False(Busy(s, "a", 2)); + Assert.True(s.RecordBusy("a", T0.AddHours(2), out var passes, out var first)); + Assert.Equal(BoundedDdlBusyStreaks.EscalateAfter, passes); + Assert.Equal(T0, first); + Assert.False(Busy(s, "a", 1)); + Assert.False(Busy(s, "a", 1)); + } + + [Fact] + public void AnApplied_ClearsTheStreak_AndReportsItHadEscalated_ThenItCanEscalateAgain() + { + var s = new BoundedDdlBusyStreaks(); + Assert.True(Busy(s, "a", 3)); + Assert.True(s.Clear("a", out var passes)); + Assert.Equal(3, passes); + Assert.False(s.Clear("a", out _)); + Assert.False(Busy(s, "a", 2)); + Assert.True(Busy(s, "a", 1)); + } + + [Fact] + public void AClear_BeforeEscalation_ReportsNoEscalation_AndRestartsTheCount() + { + var s = new BoundedDdlBusyStreaks(); + Assert.False(Busy(s, "a", 2)); + Assert.False(s.Clear("a", out _)); + Assert.False(Busy(s, "a", 2)); + } + + [Fact] + public void TwoObjects_DoNotShareAStreak() + { + var s = new BoundedDdlBusyStreaks(); + Assert.False(Busy(s, "a", 2)); + Assert.False(Busy(s, "b", 2)); + Assert.True(Busy(s, "a", 1)); + Assert.True(Busy(s, "b", 1)); + } + + [Fact] + public void TheWiring_ClearsOnApplied_AndOnFailed_AndEscalatesOnBusy() + { + var text = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "TimescaleSupport.cs").Replace("\r\n", "\n"); + var start = text.IndexOf("internal static async Task TryRunBoundedDdlAsync(", StringComparison.Ordinal); + Assert.True(start > 0); + var body = text.Substring(start, text.IndexOf("\n }\n", start, StringComparison.Ordinal) - start); + Assert.Contains("BusyStreaks.RecordBusy(what", body, StringComparison.Ordinal); + /* Applied, Failed, and (#5574) a relation that is gone: a chunk dropped by retention must not leave its streak behind. */ + Assert.Equal(3, body.Split("BusyStreaks.Clear(what", StringSplitOptions.None).Length - 1); + Assert.Contains("LogWarning", body, StringComparison.Ordinal); + } +} + +/* #1776 own-store: this fact mints its own scratch database through ScratchPostgres and never touches another + store. */ +public sealed class BoundedDdlBusyStreakLiveTests +{ + [Fact] + public async Task ABusyTable_WarnsOnceOnTheThirdPass_AndSaysSoWhenItConverges() + { + var baseConnectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(baseConnectionString), "Set DARLING_TEST_PG to run the live busy-streak pin."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString!, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + Assert.True(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct), "TimescaleDB must be available"); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, ct); + + // A key no other test records into, so the process-wide streaks stay untouched. + var What = $"compression settings on collect.wait_stats (busy-streak test {Guid.NewGuid():N})"; + var statements = new[] { "ALTER TABLE collect.wait_stats SET (timescaledb.compress)" }; + var log = new CapturingTestLogger(); + + using var holder = new NpgsqlConnection(scratch.ConnectionString); + await holder.OpenAsync(ct); + var holding = true; + try + { + await Exec(holder, "BEGIN", ct); + await Exec(holder, "SELECT 1 FROM collect.wait_stats LIMIT 1", ct); + + for (var i = 0; i < BoundedDdlBusyStreaks.EscalateAfter; i++) + { + var outcome = await TimescaleSupport.TryRunBoundedDdlAsync(connection, statements, log, What, ct); + Assert.Equal(TimescaleSupport.BoundedDdlOutcome.LockBusy, outcome); + } + + Assert.Equal(1, log.CountAtLevel(LogLevel.Warning)); + Assert.Contains(log.Lines, l => l.StartsWith("Warning:", StringComparison.Ordinal) && l.Contains(What, StringComparison.Ordinal)); + + await Exec(holder, "ROLLBACK", ct); + holding = false; + var applied = await TimescaleSupport.TryRunBoundedDdlAsync(connection, statements, log, What, ct); + Assert.Equal(TimescaleSupport.BoundedDdlOutcome.Applied, applied); + Assert.Equal(1, log.CountAtLevel(LogLevel.Warning)); + Assert.Contains(log.Lines, l => l.StartsWith("Information:", StringComparison.Ordinal) && l.Contains("busy passes", StringComparison.Ordinal)); + } + finally + { + if (holding) + { + await Exec(holder, "ROLLBACK", System.Threading.CancellationToken.None); + } + } + } + + private static async Task Exec(NpgsqlConnection connection, string sql, System.Threading.CancellationToken ct) + { + using var command = new NpgsqlCommand(sql, connection) { CommandTimeout = 60 }; + await command.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/BoundedDdlSingleCopyTests.cs b/Darling/Darling.Tests/BoundedDdlSingleCopyTests.cs new file mode 100644 index 0000000000..40ff6dd58d --- /dev/null +++ b/Darling/Darling.Tests/BoundedDdlSingleCopyTests.cs @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using Xunit; + +namespace Darling.Tests; + +/// +/// The bounded compression DDL has one copy: TimescaleSupport.TryRunBoundedDdlAsync is the only place that +/// sets the hourly lock timeout, and every compression-enable ALTER goes through it (#4970). +/// +public sealed class BoundedDdlSingleCopyTests +{ + private static string Storage() => + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Storage", "TimescaleSupport.cs").Replace("\r\n", "\n"); + + private static string[] CodeLines() => + CSharpSourceWalker.StripCommentsAndStrings(Storage()).Split('\n'); + + [Fact] + public void TheHourlyLockTimeout_IsSetInOneMethodOnly() + { + var text = Storage(); + const string setLocal = "SET LOCAL lock_timeout = '{HourlyDdlLockTimeout}'"; + var first = text.IndexOf(setLocal, StringComparison.Ordinal); + Assert.True(first >= 0, "could not find the helper's SET LOCAL lock_timeout"); + Assert.Equal(-1, text.IndexOf(setLocal, first + 1, StringComparison.Ordinal)); + var helper = text.IndexOf("internal static async Task TryRunBoundedDdlAsync(", StringComparison.Ordinal); + Assert.True(helper >= 0 && helper < first && first - helper < 1200, + "the one SET LOCAL lock_timeout must sit inside TryRunBoundedDdlAsync"); + /* Two overloads since #5574 (the statement-list one calls the body one, which owns the SET LOCAL above); a third + would be a second copy of the helper. */ + Assert.Equal(2, text.Split("BoundedDdlOutcome> TryRun", StringSplitOptions.None).Length - 1); + } + + [Fact] + public void EveryCompressionEnableAlter_IsAStatementPassedToTheHelper() + { + var lines = CodeLines(); + var uses = lines.Select((l, i) => (l, i)) + .Where(x => (x.l.Contains("EnableCompressionSql(", StringComparison.Ordinal) || x.l.Contains("EnableAggregateCompressionSql(", StringComparison.Ordinal)) + && !x.l.Contains("public static string", StringComparison.Ordinal) + && !x.l.Contains("return EnableCompressionSql(", StringComparison.Ordinal)) + .ToArray(); + Assert.Equal(3, uses.Length); + foreach (var (line, index) in uses) + { + Assert.Contains("new[] {", line, StringComparison.Ordinal); + Assert.Contains("TryRunBoundedDdlAsync(", lines[index - 1], StringComparison.Ordinal); + } + + Assert.DoesNotContain("new NpgsqlCommand(EnableCompressionSql(", Storage(), StringComparison.Ordinal); + Assert.DoesNotContain("new NpgsqlCommand(EnableAggregateCompressionSql(", Storage(), StringComparison.Ordinal); + } + + [Fact] + public void ThePrivateCollectionLogCopy_IsGone() + { + var text = Storage(); + Assert.DoesNotContain("TrySetCollectionLogCompressionAsync", text, StringComparison.Ordinal); + Assert.DoesNotContain("CollectionLogSettingsLockTimeout", text, StringComparison.Ordinal); + } + + [Fact] + public void AFailedSettingsRead_SkipsThePolicyCall_OnTheHourlyPass() + { + var text = Storage(); + var start = text.IndexOf("public static async Task ApplyCompressionPolicyAsync(", StringComparison.Ordinal); + Assert.True(start >= 0); + var skip = text.IndexOf("if (skipEnable)\n {\n continue;", start, StringComparison.Ordinal); + var policy = text.IndexOf("new NpgsqlCommand(AddCompressionPolicySql(schema)", start, StringComparison.Ordinal); + Assert.True(skip > start && policy > skip, + "the per-table policy call must be skipped when the settings read failed on the hourly pass"); + } +} diff --git a/Darling/Darling.Tests/BugReportTemplateDiagnosticsBundleTests.cs b/Darling/Darling.Tests/BugReportTemplateDiagnosticsBundleTests.cs new file mode 100644 index 0000000000..6bf39c420b --- /dev/null +++ b/Darling/Darling.Tests/BugReportTemplateDiagnosticsBundleTests.cs @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Service; +using Xunit; + +namespace Darling.Tests; + +/// #5097: the bug-report template asks for the bundle, with the verb spelled as the product spells it. +public sealed class BugReportTemplateDiagnosticsBundleTests +{ + private static string Template() => + RepoFile.ReadRepoFile(".github", "ISSUE_TEMPLATE", "bug_report.yml").ReplaceLineEndings("\n"); + + [Fact] + public void Template_HasTheBundleField_WithTheVerbFromThePredicate() + { + var yml = Template(); + Assert.Contains("id: diagnostics-bundle", yml, StringComparison.Ordinal); + Assert.Contains(DarlingCliCommands.DiagnosticsBundleVerbName + " darling-bundle.json", yml, StringComparison.Ordinal); + Assert.True(DarlingCliCommands.IsDiagnosticsBundleVerb(DarlingCliCommands.DiagnosticsBundleVerbName)); + Assert.Contains("Open it and check it before you attach it", yml, StringComparison.Ordinal); + var field = yml[yml.IndexOf("id: diagnostics-bundle", StringComparison.Ordinal)..]; + field = field[..field.IndexOf("id: screenshots", StringComparison.Ordinal)]; + Assert.Contains("required: false", field, StringComparison.Ordinal); + } + + [Fact] + public void ErrorsField_NamesTheDarlingLogPath() + { + var yml = Template(); + var errors = yml[yml.IndexOf("id: errors", StringComparison.Ordinal)..]; + errors = errors[..errors.IndexOf("id: diagnostics-bundle", StringComparison.Ordinal)]; + Assert.Contains(@"%ProgramData%\PerformanceMonitorDarling\logs\", errors, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs b/Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs index a3ce628c56..b531ed981b 100644 --- a/Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs +++ b/Darling/Darling.Tests/BuiltinAlertPersistenceRungTests.cs @@ -29,6 +29,7 @@ namespace Darling.Tests; /// claims, and they are stated against this rung's own NUMBER — read out of the ladder rather than taken /// from , which is no longer it. /// +[Trait("Reads", "Lite")] public sealed class BuiltinAlertPersistenceRungTests { /// This rung's number, DERIVED from the ladder entry that owns the name rather than written as diff --git a/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs b/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs index edf5582513..7d8732417a 100644 --- a/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs +++ b/Darling/Darling.Tests/CaggGroupIndexDropLiveTests.cs @@ -318,6 +318,86 @@ the group index still present — the same fixture shape CaggGroupIndexDropUpgra /// A future rung that adds a new probe sentinel must add its artifact's removal here. private static async Task DropArtifactsOfLaterRungsAsync(NpgsqlConnection connection, int simulatedVersion, CancellationToken ct) { + if (simulatedVersion < QueryStoreComposeStamp.RungVersion) + { + /* V173 (#5582) - the stamp-grain rollup of the wide Query Store table, its validity tables and the two late-row triggers on the + wide parent; the hours table is the probe's sentinel. The triggers go first: the V171 rewind below renames the table they sit on. */ + await using var dropStamp = new NpgsqlCommand( + "DROP TRIGGER IF EXISTS trg_query_store_compose_stamp_late_ins ON collect.query_store_interval_wide;" + + " DROP TRIGGER IF EXISTS trg_query_store_compose_stamp_late_upd ON collect.query_store_interval_wide;" + + " DROP TABLE IF EXISTS collect.query_store_compose_stamp_hours, collect.query_store_compose_stamp_built, collect.query_store_compose_stamp;" + + " DROP FUNCTION IF EXISTS collect.query_store_compose_stamp_mark_late();", connection); + await dropStamp.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < QueryStoreIntervalPartitionRungTests.LatestRungVersion) + { + /* V172 (#5571) - the latest Query Store interval table is partitioned; its relkind is the probe's sentinel. */ + await QueryStoreIntervalPartitionRungTests.RewindTableAsync(connection, "query_store_interval_latest", ct); + } + + if (simulatedVersion < QueryStoreIntervalPartitionRungTests.WideRungVersion) + { + /* V171 (#5571) - the same for the wide table. */ + await QueryStoreIntervalPartitionRungTests.RewindTableAsync(connection, "query_store_interval_wide", ct); + } + + if (simulatedVersion < PgIoStatsHourly.RungVersion) + { + /* V170 (#5495) - the hourly rollup of pg_io_stats and its state table; the state table is the probe's sentinel. */ + await using var dropIoHourly = new NpgsqlCommand( + "DROP TABLE IF EXISTS collect.pg_io_stats_hourly_state, collect.pg_io_stats_hourly", connection); + await dropIoHourly.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < 169) + { + /* V169 (#5452) - the per-server AWS role on config_monitored_servers; the role column is the probe's sentinel. + Dropping the columns drops the table's check on them too. */ + await using var dropAwsRole = new NpgsqlCommand( + "ALTER TABLE config.config_monitored_servers DROP COLUMN IF EXISTS aws_external_id_set, " + + "DROP COLUMN IF EXISTS aws_external_id, DROP COLUMN IF EXISTS aws_role_arn", connection); + await dropAwsRole.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < 168) + { + /* V168 (#5448) - the PLAN_REGRESSION per-day totals; the built table is the probe's sentinel. The trigger on + query_store_interval_latest goes first, then the function it calls, then the two tables. */ + await using var dropPlanRegressionDaily = new NpgsqlCommand( + "DROP TRIGGER IF EXISTS trg_plan_regression_daily_late ON collect.query_store_interval_latest;" + + " DROP FUNCTION IF EXISTS collect.plan_regression_daily_mark_late();" + + " DROP TABLE IF EXISTS collect.plan_regression_daily, collect.plan_regression_daily_built", connection); + await dropPlanRegressionDaily.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < LegacyPinCandidateTables.RungVersion) + { + /* V167 (#5456) - the table that records the old-format values present at the upgrade; + config.legacy_secret_pin_candidate is the probe's sentinel. It goes before the V165 arm below, because its + triggers use the function that arm drops. */ + await using var dropCandidates = new NpgsqlCommand("DROP TABLE IF EXISTS config.legacy_secret_pin_candidate", connection); + await dropCandidates.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < 166) + { + /* V166 - the PagerDuty auto-resolve column on config_notification; the column is the probe's sentinel. */ + await using var dropAutoResolve = new NpgsqlCommand( + "ALTER TABLE config.config_notification DROP COLUMN IF EXISTS pagerduty_auto_resolve", connection); + await dropAutoResolve.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < PasswordKeyTables.RungVersion) + { + /* V165 (#5366) - the tables for the service's password key; config.password_key is the probe's sentinel. + The four tables go first, which also drops their triggers, and then the trigger function they shared. */ + await using var dropPasswordKey = new NpgsqlCommand( + "DROP TABLE IF EXISTS config.password_key, config.password_key_service, config.legacy_secret_pin, config.legacy_secret_pin_marker;" + + " DROP FUNCTION IF EXISTS config.password_key_owner_only()", connection); + await dropPasswordKey.ExecuteNonQueryAsync(ct); + } + if (simulatedVersion < 153) { /* V153 (#4608, split #4615) — the interval-tables-latest first_execution_time index. */ @@ -343,6 +423,64 @@ private static async Task DropArtifactsOfLaterRungsAsync(NpgsqlConnection connec await dropEnd.ExecuteNonQueryAsync(ct); } + if (simulatedVersion < QueryStatsHourLedgerTests.RungVersion) + { + /* V164 (#4605) - the hourly row-count ledger; the ledger table is the probe's sentinel. */ + await using var dropLedger = new NpgsqlCommand( + "DROP TABLE IF EXISTS collect.query_stats_hour_ledger_state, collect.query_stats_hour_ledger", connection); + await dropLedger.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < StoreStatementHistoryRungTests.RungVersion) + { + /* V163 (#5097) - the store's statement history; the history table is the probe's sentinel. */ + await using var dropStatementHistory = new NpgsqlCommand( + "DROP TABLE IF EXISTS collect.store_statement_history, collect.store_statement_captures, config.store_statement_baseline", connection); + await dropStatementHistory.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < SlowReadsRungTests.RungVersion) + { + /* V162 (#5097) - the slow-read record; the table is the probe's sentinel. */ + await using var dropSlowReads = new NpgsqlCommand( + "DROP TABLE IF EXISTS collect.slow_reads", connection); + await dropSlowReads.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < QueryStoreTopDailyRungTests.RungVersion) + { + /* V161 (#5094) - the Query Store top daily summary tables; the first is the probe's sentinel. */ + await using var dropTopDaily = new NpgsqlCommand( + "DROP TABLE IF EXISTS collect.query_store_top_daily, collect.query_store_top_daily_built", connection); + await dropTopDaily.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < CollectorRunTimeRungTests.RungVersion) + { + /* V160 (#4938) - the collector run time, in a table of its own; the table is the probe's sentinel. */ + await using var dropRunAt = new NpgsqlCommand( + "DROP TABLE IF EXISTS config.config_collector_run_times", connection); + await dropRunAt.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < InstallIdTableOidRungTests.RungVersion) + { + /* V159 (#4961) - the install id table's own OID and the server's major version, which arrive in the one + statement; the OID's column is the probe's sentinel. IF EXISTS on the table, because a store simulated below + the install id's own rung has had the table dropped already. */ + await using var dropTableOid = new NpgsqlCommand( + "ALTER TABLE IF EXISTS config.config_install_id DROP COLUMN IF EXISTS table_oid, DROP COLUMN IF EXISTS server_major", connection); + await dropTableOid.ExecuteNonQueryAsync(ct); + } + + if (simulatedVersion < InstallIdRungTests.RungVersion) + { + /* V158 (#4961) - the install id's one-row table; the table is the probe's sentinel. */ + await using var dropInstallId = new NpgsqlCommand( + "DROP TABLE IF EXISTS config.config_install_id", connection); + await dropInstallId.ExecuteNonQueryAsync(ct); + } + if (simulatedVersion < MuteRuleServerIdRungTests.RungVersion) { /* V157 - the mute rule's store server id; the column is the probe's sentinel. */ diff --git a/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt b/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt index 5df7eba883..666e6ac573 100644 --- a/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt +++ b/Darling/Darling.Tests/CapabilityPins/viewerservertab-eventwires.baseline.txt @@ -8,7 +8,6 @@ MouseDoubleClick=>QueryStoreRegressionsGrid_MouseDoubleClick SelectionChanged=>AutoRefreshInterval_Changed SelectionChanged=>BlockingSubTabs_SelectionChanged SelectionChanged=>CompareToCombo_SelectionChanged -SelectionChanged=>CustomTimeCombo_Changed SelectionChanged=>FileIoSubTabs_SelectionChanged SelectionChanged=>HeatmapMetric_SelectionChanged SelectionChanged=>InnerTabs_SelectionChanged @@ -19,5 +18,4 @@ SelectionChanged=>QueryStatsGrid_SelectionChanged SelectionChanged=>QueryStoreGrid_SelectionChanged SelectionChanged=>SystemEventsSubTabs_SelectionChanged SelectionChanged=>TimeDisplayMode_SelectionChanged -SelectionChanged=>TimeRangeCombo_SelectionChanged SelectionChanged=>WaitStatsMetric_SelectionChanged diff --git a/Darling/Darling.Tests/CaptureDownChunkOrderTests.cs b/Darling/Darling.Tests/CaptureDownChunkOrderTests.cs index e9ff606b5f..ce5e3e5088 100644 --- a/Darling/Darling.Tests/CaptureDownChunkOrderTests.cs +++ b/Darling/Darling.Tests/CaptureDownChunkOrderTests.cs @@ -126,7 +126,8 @@ outside the collector catalog and so outside ConvertToHypertablesAsync. */ /* Eight days of one-minute blocked_process_report and five-minute deadlocks rows, plus a filler collector so the newest chunk holds rows the arms must skip past. All Kind-Unspecified: naive-UTC storage, see DarlingObservability.LogCollectionAsync. */ - var utcNow = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + /* #5608: the end of the window is a fixed point inside one chunk, so the chunk layout the plan is read against is the same at any hour. */ + var utcNow = LiveClock.AnchoredEnd(); await SeedHistoryAsync(connection, utcNow, ct); /* The two verdict rows. deadlocks: SESSION_MISSING three days ago, then a SUCCESS a minute ago — NOT diff --git a/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs b/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs index cbf59ac543..bd486d2892 100644 --- a/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs +++ b/Darling/Darling.Tests/ChartIntegerTicksPinTests.cs @@ -34,6 +34,7 @@ namespace Darling.Tests; /// a 0-1 axis gives ticks 0 and 1, 0-2 gives 0, 1, 2, and for every maximum from 1 to 5000 the ticks are whole, /// distinct and cover the maximum. /// +[Trait("Stage", "Guard")] public sealed class ChartIntegerTicksPinTests { private static string Js(params string[] path) => ReadRepoFileLf(Path.Combine( diff --git a/Darling/Darling.Tests/CiShardCommandLineBudgetTests.cs b/Darling/Darling.Tests/CiShardCommandLineBudgetTests.cs new file mode 100644 index 0000000000..d51bc5809b --- /dev/null +++ b/Darling/Darling.Tests/CiShardCommandLineBudgetTests.cs @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Text.RegularExpressions; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5100: the sharded test steps in build.yml pass every class of the shard as a -class +/// argument. Windows CreateProcess caps the whole command line at 32,767 characters, and one Darling +/// shard's arguments alone reached ~32,660. Each step therefore splits its class list into chunks under a +/// named character budget and runs the test host once per chunk. These are source pins, because the step only +/// runs on a Windows runner. #5616: that logic is one PowerShell script per suite +/// (.github/scripts/run-darling-pg-shard.ps1, run-lite-shard.ps1) that build.yml's PR legs and +/// nightly.yml's legs both call, so the pins read the scripts; the workflow steps only pass arguments. +/// +public sealed class CiShardCommandLineBudgetTests +{ + /// The CreateProcess limit minus the ~170 characters the rest of the command takes, rounded down. + private const int MaxBudget = 30000; + + [Theory] + [InlineData("Run Darling PG tests")] + [InlineData("Run Lite tests (shard)")] + public void ShardStep_RunsTheTestHostOncePerChunk_UnderTheBudget(string stepName) + { + var step = ReadStep(stepName); + + var budget = Regex.Match(step, @"\$argBudget\s*=\s*(?\d+)"); + Assert.True(budget.Success, $"'{stepName}' names no $argBudget, so its -class list is not chunked."); + Assert.True(int.Parse(budget.Groups["n"].Value) <= MaxBudget, + $"'{stepName}' budget {budget.Groups["n"].Value} leaves less than 2,767 characters under the 32,767 limit."); + + Assert.Matches(@"(?s)foreach\s*\(\$chunk\s+in\s+\$chunks\)\s*\{.*dotnet\s+run\s+--project\s+\S+.*@chunkArgs", step); + Assert.Matches(@"-gt\s+\$argBudget", step); + } + + [Theory] + [InlineData("Run Darling PG tests")] + [InlineData("Run Lite tests (shard)")] + public void ShardStep_NeverPassesTheWholeShardListInOneInvocation(string stepName) + { + var step = ReadStep(stepName); + + foreach (Match run in Regex.Matches(step, @"dotnet\s+run\s+[^\r\n]*")) + { + if (run.Value.Contains("-list classes/json", StringComparison.Ordinal)) + { + continue; + } + Assert.DoesNotContain("$mine", run.Value); + Assert.DoesNotContain("@shardArgs", run.Value); + Assert.Contains("@chunkArgs", run.Value); + } + Assert.DoesNotContain("@shardArgs", step); + } + + [Fact] + public void ShardSteps_RunEveryChunk_AndFailTheStepWhenAnyChunkFailed() + { + foreach (var stepName in new[] { "Run Darling PG tests", "Run Lite tests (shard)" }) + { + var step = ReadStep(stepName); + Assert.Matches(@"\$LASTEXITCODE\s+-ne\s+0\)\s*\{\s*\$failedChunks\+\+", step); + Assert.Matches(@"\$failedChunks\s+-gt\s+0\)[^\r\n]*exit\s+1", step); + } + } + + [Fact] + public void DarlingStep_GivesEachChunkItsOwnTrx() + { + var step = ReadStep("Run Darling PG tests"); + Assert.Contains("TestResults/$ResultPrefix-${Shard}-$chunkNumber.trx", step); + + // ...and each caller names its own prefix, so the PR legs and the nightly's legs never write the same file. + Assert.Contains("-ResultPrefix darling-pr ", ReadWorkflowStep("build.yml", "Run Darling PG tests")); + Assert.Contains("-ResultPrefix darling-nightly ", ReadWorkflowStep("nightly.yml", "Run Darling PG tests")); + } + + [Fact] + public void ShardSteps_ChunkingBlockIsIdentical_AndNativeErrorPreferenceIsOff() + { + var darling = ReadStep("Run Darling PG tests"); + var lite = ReadStep("Run Lite tests (shard)"); + Assert.Equal(ChunkBlock(darling), ChunkBlock(lite), StringComparer.Ordinal); + + foreach (var step in new[] { darling, lite }) + { + var pref = step.IndexOf("$PSNativeCommandUseErrorActionPreference = $false", StringComparison.Ordinal); + var loop = step.IndexOf("foreach ($chunk in $chunks)", StringComparison.Ordinal); + Assert.True(pref >= 0 && pref < loop, "the step must turn the native-command error preference off before the chunk loop."); + } + } + + private static string ChunkBlock(string step) + { + var start = step.IndexOf("$argBudget =", StringComparison.Ordinal); + const string endMarker = "if ($current.Count -gt 0) { $chunks.Add($current.ToArray()) }"; + var end = step.IndexOf(endMarker, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start, "the step has no chunking block."); + return step.Substring(start, end + endMarker.Length - start).Replace("\r\n", "\n"); + } + + /// The shard script the named step calls (#5616), linked into Fixtures by Darling.Tests.csproj. + private static string ReadStep(string stepName) + { + var file = stepName == "Run Darling PG tests" ? "run-darling-pg-shard.ps1" : "run-lite-shard.ps1"; + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", file); + Assert.True(File.Exists(path), $"{file} was not copied beside the test binary (Darling.Tests.csproj links it into Fixtures\\)."); + return File.ReadAllText(path).Replace("\r\n", "\n"); + } + + private static string ReadWorkflowStep(string workflow, string stepName) + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", workflow); + Assert.True(File.Exists(path), $"{workflow} was not copied beside the test binary (Darling.Tests.csproj links it into Fixtures\\)."); + var text = File.ReadAllText(path); + var start = text.IndexOf("- name: " + stepName, StringComparison.Ordinal); + Assert.True(start >= 0, $"{workflow} has no step named '{stepName}'."); + var next = Regex.Match(text[(start + 1)..], @"\r?\n - name: |\r?\n [a-z][\w-]*:\r?\n"); + return next.Success ? text.Substring(start, next.Index + 1) : text[start..]; + } +} diff --git a/Darling/Darling.Tests/CiShardHangReportTests.cs b/Darling/Darling.Tests/CiShardHangReportTests.cs new file mode 100644 index 0000000000..5da346b654 --- /dev/null +++ b/Darling/Darling.Tests/CiShardHangReportTests.cs @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5587: run 37824250187 attempt 1, Darling PG shard 3, ran to the JOB's 30-minute timeout. GitHub cancelled the +/// job, left every later step (the stop, both uploads) pending, kept no log, and the per-test timing was never +/// written, so nothing said which test was running or what it waited on. The shard test steps now carry a +/// step-level timeout (the step fails, the job goes on, and the always() steps run), the runner's +/// -longRunning report (a test that has run for 5 minutes is named in the log while the step is alive) and a +/// kept copy of the runner output. These are source pins on the workflow text, because the steps only run on a +/// Windows runner. +/// +public sealed class CiShardHangReportTests +{ + /// Under the job's 30 minutes with room for the setup before the step and the stop and uploads after it. + private const int MaxStepTimeoutMinutes = 25; + + /// The slowest normal Darling test measured 120 s (the store-upgrade classes), so a report below it is noise. + private const int MinLongRunningSeconds = 150; + + /// Well under the step timeout, or the report would arrive after the step was killed. + private const int MaxLongRunningSeconds = 600; + + [Theory] + [InlineData("Run Darling PG tests")] + [InlineData("Run Lite tests (shard)")] + public void ShardStep_HasAStepTimeout_UnderTheJobTimeout(string stepName) + { + var step = ReadStep(stepName); + + var timeout = Regex.Match(step, @"(?m)^ timeout-minutes:\s*(?\d+)\s*$"); + Assert.True(timeout.Success, + $"'{stepName}' has no step-level timeout-minutes. The job's own timeout cancels the job and leaves the upload steps pending (#5587)."); + var minutes = int.Parse(timeout.Groups["n"].Value); + Assert.InRange(minutes, 10, MaxStepTimeoutMinutes); + } + + [Theory] + [InlineData("Run Darling PG tests")] + [InlineData("Run Lite tests (shard)")] + public void ShardStep_AsksTheRunnerToNameALongRunningTest_AndKeepsTheRunnerOutput(string stepName) + { + // #5616: the chunk loop lives in the shard script both workflows call, so the runner's flags are read from it. + var step = ReadShardScript(stepName); + + var longRunning = Regex.Match(step, @"dotnet\s+run\s+[^\r\n]*@chunkArgs[^\r\n]*\s-longRunning\s+(?\d+)\b"); + Assert.True(longRunning.Success, $"'{stepName}' does not pass -longRunning to the chunk's runner, so a hung test is not named."); + Assert.InRange(int.Parse(longRunning.Groups["n"].Value), MinLongRunningSeconds, MaxLongRunningSeconds); + + Assert.Matches(@"dotnet\s+run\s+[^\r\n]*@chunkArgs[^\r\n]*\|\s*Tee-Object\s+-FilePath\s+TestResults/(darling|lite)-runner-\$\{Shard\}\.log\s+-Append", step); + Assert.Matches(@"New-Item\s+-ItemType\s+Directory\s+-Force\s+-Path\s+TestResults", step); + } + + [Fact] + public void DarlingUploads_RunWhateverTheTestStepDid() + { + var timings = ReadStep("Upload Darling test timings"); + Assert.Matches(@"(?m)^ if:\s*always\(\)", timings); + + var runner = ReadStep("Upload Darling runner output"); + Assert.Matches(@"(?m)^ if:\s*always\(\)", runner); + Assert.Contains("TestResults/darling-runner-*.log", runner, StringComparison.Ordinal); + Assert.Contains("darling-runner-log-${{ matrix.shard }}", runner, StringComparison.Ordinal); + Assert.Matches(@"(?m)^ timeout-minutes:\s*\d+", runner); + + var failure = ReadStep("Upload PG log and test results on failure"); + Assert.Matches(@"(?m)^ if:\s*\(failure\(\) \|\| cancelled\(\)\)", failure); + } + + [Fact] + public void LiteUploads_RunWhateverTheTestStepDid() + { + var timings = ReadStep("Upload Lite test timings"); + Assert.Matches(@"(?m)^ if:\s*always\(\)", timings); + + var runner = ReadStep("Upload Lite runner output"); + Assert.Matches(@"(?m)^ if:\s*always\(\)", runner); + Assert.Contains("TestResults/lite-runner-*.log", runner, StringComparison.Ordinal); + Assert.Contains("lite-runner-log-${{ matrix.shard }}", runner, StringComparison.Ordinal); + } + + /// + /// #5587, #5616, nightly.yml: the nightly's shard steps call the same scripts as build.yml's, so the hang report + /// (-longRunning and the teed runner log) is the script's, and the step must pass the script's shard + /// arguments and no event: the nightly runs every class, so it must not pass -EventName. + /// + [Theory] + [InlineData("Run Lite tests (shard)", "run-lite-shard.ps1")] + [InlineData("Run Darling PG tests", "run-darling-pg-shard.ps1")] + public void NightlyStep_CallsTheSharedShardScript_WithItsShardAndNoEvent(string stepName, string script) + { + var step = ReadStep(stepName, "nightly.yml"); + + Assert.Matches(@"(?m)^ shell:\s*pwsh\s*$", step); + Assert.Contains("./.github/scripts/" + script + " -Shard ${{ matrix.shard }} -ShardCount ${{ strategy.job-total }}", step, StringComparison.Ordinal); + Assert.DoesNotContain("-EventName", step, StringComparison.Ordinal); + Assert.DoesNotContain("-GuardBuildUsed", step, StringComparison.Ordinal); + Assert.DoesNotContain("-TimingRun", step, StringComparison.Ordinal); + Assert.DoesNotContain("dotnet run", step, StringComparison.Ordinal); + + // The hang report and the exit-code propagation live once, in the script: the runner is asked for -longRunning + // and its output is teed, and a failing chunk fails the script (and so the step). + var body = ReadShardScript(stepName); + var longRunning = Regex.Match(body, @"dotnet\s+run\s+[^\r\n]*@chunkArgs[^\r\n]*\s-longRunning\s+(?\d+)\b"); + Assert.True(longRunning.Success, $"{script} does not pass -longRunning to the chunk's runner, so a hung test is not named."); + Assert.InRange(int.Parse(longRunning.Groups["n"].Value), MinLongRunningSeconds, MaxLongRunningSeconds); + Assert.Matches(@"\$failedChunks\s+-gt\s+0\)[^\r\n]*exit\s+1", body); + } + + /// + /// #5587, #5616, nightly.yml: step timeouts from numbers. Lite: build.yml's four legs ran the step for 5.6 to 19.9 + /// minutes over the last 7 successful dev pushes; the nightly's hash cut is less even, so the step gets twice that + /// (40) in a job of 50, and 19.9 is 50% of it, under the 70% bar. Darling: build.yml's six legs ran 5.5 to 10.9 + /// minutes, the step gets build.yml's 22 in a job of 40, and 10.9 is 50% of it. + /// + [Fact] + public void NightlyStepTimeouts_FollowTheMeasuredNumbers() + { + var yml = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "nightly.yml")).Replace("\r\n", "\n"); + foreach (var (job, stepName, maxJob) in new[] { ("lite-tests", "Run Lite tests (shard)", 50), ("darling-pg", "Run Darling PG tests", 40) }) + { + var step = ReadStep(stepName, "nightly.yml"); + var timeout = Regex.Match(step, @"(?m)^ timeout-minutes:\s*(?\d+)\s*$"); + Assert.True(timeout.Success, $"nightly.yml '{stepName}' has no step-level timeout-minutes."); + + var jobStart = yml.IndexOf("\n " + job + ":\n", StringComparison.Ordinal); + Assert.True(jobStart >= 0, $"nightly.yml has no job '{job}'."); + var jobTimeout = Regex.Match(yml[jobStart..], @"(?m)^ timeout-minutes:\s*(?\d+)\s*$"); + Assert.True(jobTimeout.Success && int.Parse(jobTimeout.Groups["n"].Value) == maxJob, + $"nightly.yml job '{job}' timeout is not {maxJob}."); + Assert.InRange(int.Parse(timeout.Groups["n"].Value), 20, maxJob - 10); + } + } + + [Fact] + public void NightlyUploads_RunWhateverTheTestStepDid() + { + var lite = ReadStep("Upload Lite runner output", "nightly.yml"); + Assert.Matches(@"(?m)^ if:\s*always\(\)", lite); + Assert.Contains("TestResults/lite-runner-*.log", lite, StringComparison.Ordinal); + Assert.Contains("lite-runner-log-nightly-${{ matrix.shard }}", lite, StringComparison.Ordinal); + Assert.Matches(@"(?m)^ timeout-minutes:\s*\d+", lite); + + var darling = ReadStep("Upload Darling runner output", "nightly.yml"); + Assert.Matches(@"(?m)^ if:\s*always\(\)", darling); + Assert.Contains("TestResults/darling-runner-*.log", darling, StringComparison.Ordinal); + Assert.Contains("darling-runner-log-nightly-${{ matrix.shard }}", darling, StringComparison.Ordinal); + Assert.Matches(@"(?m)^ timeout-minutes:\s*\d+", darling); + + var failure = ReadStep("Upload PG log and test results on failure", "nightly.yml"); + Assert.Matches(@"(?m)^ if:\s*failure\(\) \|\| cancelled\(\)", failure); + + Assert.DoesNotContain("-runner-", ReadStep("Upload Lite test timings", "nightly.yml"), StringComparison.Ordinal); + } + + [Fact] + public void RunnerLogs_AreTheirOwnArtifacts_NotPartOfTheTimingArtifacts() + { + // The shadow check and the Lite shard packer read every file of the *-tests-timing-* artifacts. + foreach (var name in new[] { "Upload Darling test timings", "Upload Lite test timings" }) + { + Assert.DoesNotContain("-runner-", ReadStep(name), StringComparison.Ordinal); + } + } + + /// + /// #5587: the shard-3 classes were read for a wait with no end. The one found is the cancelled refresh in + /// RollupBackfillLiveTests: it waited on a task whose only way out was a cancel to a backend that was blocked + /// on a lock the test itself still held, and the refresh's own command timeout is an hour. The wait is bounded now. + /// + [Fact] + public void RollupBackfillLiveTests_TheCancelledRefreshWait_IsBoundedWhileTheChunkLockIsHeld() + { + var source = ReadSiblingSource("RollupBackfillLiveTests.cs"); + + Assert.Contains("refresh.WaitAsync(CancelledRefreshLimit", source, StringComparison.Ordinal); + Assert.Single(Regex.Matches(source, @"_ = await refresh;")); + var released = source.IndexOf("lockTransaction.RollbackAsync(CancellationToken.None)", StringComparison.Ordinal); + Assert.True(released >= 0, "the chunk lock is no longer released in a finally."); + Assert.True( + source.IndexOf("if (cancelDidNotStopTheRefresh)", released, StringComparison.Ordinal) is var guarded && guarded > released + && source.IndexOf("_ = await refresh;", guarded, StringComparison.Ordinal) > guarded, + "the only unbounded await of the refresh must come after the chunk lock is released."); + } + + private static string ReadSiblingSource(string file, [CallerFilePath] string callerFile = "") => + File.ReadAllText(Path.Combine(Path.GetDirectoryName(callerFile)!, file)).Replace("\r\n", "\n"); + + /// The shard script the named step calls (#5616), read from the copy linked into Fixtures. + private static string ReadShardScript(string stepName) + { + var file = stepName == "Run Darling PG tests" ? "run-darling-pg-shard.ps1" : "run-lite-shard.ps1"; + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", file); + Assert.True(File.Exists(path), $"{file} was not copied beside the test binary (Darling.Tests.csproj links it into Fixtures\\)."); + return File.ReadAllText(path).Replace("\r\n", "\n"); + } + + private static string ReadStep(string stepName, string workflow = "build.yml") + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", workflow); + Assert.True(File.Exists(path), $"{workflow} was not copied beside the test binary (Darling.Tests.csproj links it into Fixtures\\)."); + var text = File.ReadAllText(path); + var start = text.IndexOf("- name: " + stepName + "\n", StringComparison.Ordinal); + if (start < 0) + { + start = text.IndexOf("- name: " + stepName + "\r\n", StringComparison.Ordinal); + } + Assert.True(start >= 0, $"{workflow} has no step named '{stepName}'."); + var next = Regex.Match(text[(start + 1)..], @"\r?\n - name: |\r?\n [a-z][\w-]*:\r?\n"); + return next.Success ? text.Substring(start, next.Index + 1) : text[start..]; + } +} diff --git a/Darling/Darling.Tests/ClipboardWriteCensusTests.cs b/Darling/Darling.Tests/ClipboardWriteCensusTests.cs index 9f3e2458a7..1752e333e2 100644 --- a/Darling/Darling.Tests/ClipboardWriteCensusTests.cs +++ b/Darling/Darling.Tests/ClipboardWriteCensusTests.cs @@ -24,6 +24,8 @@ namespace Darling.Tests; /// either call does not trip it). deprecated/ is out of scope (no work there unless asked for by /// name) and is excluded, matching every other census in this file's family. /// +[Trait("Stage", "Guard")] +[Trait("Reads", "Lite")] public sealed class ClipboardWriteCensusTests { [Fact] diff --git a/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs b/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs index f8655dc981..43f2b6783c 100644 --- a/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs +++ b/Darling/Darling.Tests/CollectionCaveatLedgerTests.cs @@ -35,6 +35,7 @@ namespace Darling.Tests; /// (analysis_time is the latest pass's, entries_from the newest FAILING pass's, so a recovery is /// readable as a recovery rather than as a current fault). /// +[Trait("Reads", "Lite")] public sealed class CollectionCaveatLedgerTests { private static readonly JsonSerializerOptions Options = new JsonSerializerOptions { WriteIndented = false }; @@ -387,6 +388,7 @@ public void TheSharedLedger_IsOneInstance_AndStampsItsOwnStart() /// /// /// +[Trait("Reads", "Lite")] public sealed class CollectionCaveatLedgerParityTests { private const string Attach = "CollectionCaveatLedger.Shared.Attach("; diff --git a/Darling/Darling.Tests/CollectionCaveatsParityTests.cs b/Darling/Darling.Tests/CollectionCaveatsParityTests.cs index 1e8bfa2706..8f7ca2210d 100644 --- a/Darling/Darling.Tests/CollectionCaveatsParityTests.cs +++ b/Darling/Darling.Tests/CollectionCaveatsParityTests.cs @@ -34,6 +34,7 @@ namespace Darling.Tests; /// serialized on a clean pass and move its bytes. The descriptions name the field on both SKUs. /// /// +[Trait("Reads", "Lite")] public sealed class CollectionCaveatsParityTests { private const string Record = "context.RecordCollectionFailure("; diff --git a/Darling/Darling.Tests/CollectionCaveatsWebTests.cs b/Darling/Darling.Tests/CollectionCaveatsWebTests.cs new file mode 100644 index 0000000000..c4d6f5b7a0 --- /dev/null +++ b/Darling/Darling.Tests/CollectionCaveatsWebTests.cs @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/* #1776 own-store: each live fact mints its own scratch database through ScratchPostgres and never touches the shared + store's tables, so it cannot race the live collection. */ + +/// +/// The collection caveats on the web Collection Health tabs (#4843): get_collection_health carries a +/// stored_caveats array from collect.analysis_collection_caveats, the SQL Server and PostgreSQL tabs draw it +/// as a grid of the desktop viewer's four columns, and a server with none shows no grid. No new tool carries it. +/// +public sealed class CollectionCaveatsWebTests +{ + private static JsonElement RunHarness() + { + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false }; + psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "web-collection-caveats-harness.mjs")); + psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js")); + Process proc; + try + { + proc = Process.Start(psi)!; + } + catch (Win32Exception) + { + Assert.Skip("Node is not installed, so the shipped page scripts cannot be run."); + return default; + } + + using (proc) + { + var error = proc.StandardError.ReadToEndAsync(); + var output = proc.StandardOutput.ReadToEnd().Trim(); + if (!proc.WaitForExit(30000)) + { + proc.Kill(entireProcessTree: true); + Assert.Fail("the collection-caveats harness did not finish in 30 s"); + } + + Assert.True(proc.ExitCode == 0, "the collection-caveats harness failed: " + error.Result); + using var doc = JsonDocument.Parse(output); + return doc.RootElement.Clone(); + } + } + + [Theory] + [InlineData("sqlserver")] + [InlineData("postgres")] + public void AServerWithNoCaveats_ShowsNoGrid_AndOneWithCaveatsShowsTheFourColumns(string engine) + { + var e = RunHarness().GetProperty(engine); + + var empty = e.GetProperty("empty"); + Assert.True(empty.GetProperty("hidden").GetBoolean(), "a payload without stored_caveats must leave the panel hidden"); + Assert.Empty(empty.GetProperty("rows").EnumerateArray()); + + var rows = e.GetProperty("rows"); + Assert.False(rows.GetProperty("hidden").GetBoolean()); + Assert.Equal(new[] { "Family", "Reason", "Since", "Last seen" }, rows.GetProperty("heads").EnumerateArray().Select(h => h.GetString()).ToArray()); + var cells = rows.GetProperty("rows").EnumerateArray().Select(r => r.EnumerateArray().Select(c => c.GetString()).ToArray()).ToArray(); + Assert.Equal(2, cells.Length); + Assert.Equal(new[] { "plans", "timeout" }, cells[0].Take(2).ToArray()); + Assert.Equal(new[] { "waits", "missing_schema" }, cells[1].Take(2).ToArray()); + } + + [Theory] + [InlineData("sqlserver")] + [InlineData("postgres")] + public void WhileTheReadIsOutstanding_AServerNeverSeenShowsNoCard_AndARebuildOfOneWithCaveatsKeepsIt(string engine) + { + var e = RunHarness().GetProperty(engine); + + var pending = e.GetProperty("pending"); + Assert.True(pending.GetProperty("present").GetBoolean()); + Assert.True(pending.GetProperty("hidden").GetBoolean(), "a clean server must not flash the card while get_collection_health is still running"); + + var rebuild = e.GetProperty("rebuild"); + Assert.False(rebuild.GetProperty("hidden").GetBoolean(), "a rebuild of a server whose last read had caveats must keep the card visible"); + } + + [Theory] + [InlineData("sqlserver")] + [InlineData("postgres")] + public void TheGridRidesTheHealthRead_WithItsParams_AndNoSecondRead(string engine) + { + var e = RunHarness().GetProperty(engine); + foreach (var arm in new[] { "empty", "rows" }) + { + var a = e.GetProperty(arm); + Assert.Equal(new[] { "get_collection_health?server=srv-a" }, a.GetProperty("healthReads").EnumerateArray().Select(r => r.GetString()).ToArray()); + Assert.False(a.GetProperty("anyCaveatTool").GetBoolean()); + } + } + + [Fact] + public void BothTabsShareOnePanel_ReadingTheHealthPayloadsArray_AndNoCaveatToolExists() + { + var tabs = ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js", "pages", "server-tabs.js"); + Assert.Contains("rowsKey: \"stored_caveats\"", tabs, StringComparison.Ordinal); + Assert.Contains("hideWhenNoRows: true", tabs, StringComparison.Ordinal); + Assert.Equal(2, tabs.Split(" COLLECTION_CAVEATS_PANEL,", StringSplitOptions.None).Length - 1); + Assert.DoesNotContain("get_collection_caveats", tabs, StringComparison.Ordinal); + Assert.DoesNotContain("get_collection_caveats", ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingMcpDataTools.cs"), StringComparison.Ordinal); + } + + private static DarlingCollectionCaveatReader.Caveat Row(string family) => + new(family, "timeout", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)); + + [Fact] + public void ACleanServersPayloadGainsNoKey_AndCaveatsAreAttachedAsAnArray() + { + var cleanJson = JsonSerializer.Serialize(new { server = "a" }); + Assert.Equal(cleanJson, DarlingCollectionCaveatReader.AttachToJson(cleanJson, Array.Empty())); + + var with = DarlingCollectionCaveatReader.AttachToJson(cleanJson, new[] { Row("plans") }); + using var doc = JsonDocument.Parse(with); + var entry = Assert.Single(doc.RootElement.GetProperty("stored_caveats").EnumerateArray()); + Assert.Equal("plans", entry.GetProperty("family").GetString()); + Assert.Equal("timeout", entry.GetProperty("reason").GetString()); + Assert.StartsWith("2026-01-01T00:00:00", entry.GetProperty("first_seen_utc").GetString(), StringComparison.Ordinal); + Assert.StartsWith("2026-01-02T00:00:00", entry.GetProperty("last_seen_utc").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task AMissingTableAnswersNoCaveats_AndAnUnreadableOneAnswersNoneWithOneWarning() + { + var warnings = new List(); + var missing = await DarlingCollectionCaveatReader.ReadAsync( + () => throw new PostgresException("relation does not exist", "ERROR", "ERROR", "42P01"), warnings.Add); + Assert.Empty(missing); + Assert.Empty(warnings); + + var denied = await DarlingCollectionCaveatReader.ReadAsync( + () => throw new PostgresException("permission denied", "ERROR", "ERROR", "42501"), warnings.Add); + Assert.Empty(denied); + Assert.Single(warnings); + + await Assert.ThrowsAsync(() => DarlingCollectionCaveatReader.ReadAsync( + () => throw new PostgresException("boom", "ERROR", "ERROR", "XX000"), warnings.Add)); + } + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private static async Task HealthAsync(NpgsqlDataSource postgres, string server, CancellationToken ct) + { + var json = await DarlingMcpDataTools.GetCollectionHealth(postgres, server, cancellationToken: ct); + var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("collectors", out _), json.Length > 400 ? json[..400] : json); + return doc; + } + + [Fact] + public async Task TheHealthReadCarriesTheStoresCaveats_OmitsThemWhenNone_AndSurvivesAStoreWithoutTheTable() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), "Set DARLING_TEST_PG to a Postgres connection string to run the collection-caveat health pins (each mints its own scratch database)."); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_141; + const string server = "caveat-health-a"; + var now = DateTime.UtcNow; + await using (var register = new NpgsqlCommand( + """ + INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major_version, created_date, modified_date) + VALUES ($1, $2, $2, TRUE, 15, $3, $3) + """, connection)) + { + register.Parameters.AddWithValue(serverId); + register.Parameters.AddWithValue(server); + register.Parameters.AddWithValue(DateTime.SpecifyKind(now, DateTimeKind.Unspecified)); + await register.ExecuteNonQueryAsync(ct); + } + + await using (var log = new NpgsqlCommand( + """ + INSERT INTO collection_log (log_id, collection_time, server_id, server_name, collector_name, status, duration_ms, rows_collected) + VALUES ($1, $2, $3, $4, 'wait_stats', 'SUCCESS', 120, 10) + """, connection)) + { + log.Parameters.AddWithValue(CollectionIdGenerator.Next()); + log.Parameters.AddWithValue(DateTime.SpecifyKind(now.AddMinutes(-1), DateTimeKind.Unspecified)); + log.Parameters.AddWithValue(serverId); + log.Parameters.AddWithValue(server); + await log.ExecuteNonQueryAsync(ct); + } + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + + // No rows: the key is absent, not null and not empty. + using (var clean = await HealthAsync(postgres, server, ct)) + { + Assert.False(clean.RootElement.TryGetProperty("stored_caveats", out _)); + } + + // Two rows through the store's own writer, read back through the real tool. + await CollectionCaveatStore.ApplyPassAsync( + postgres, serverId, + new[] { new CollectionCaveatStore.UnreadFamily("waits", "missing_schema"), new CollectionCaveatStore.UnreadFamily("plans", "timeout") }, + now.AddHours(-2), null, ct); + await CollectionCaveatStore.ApplyPassAsync( + postgres, serverId, + new[] { new CollectionCaveatStore.UnreadFamily("waits", "missing_schema"), new CollectionCaveatStore.UnreadFamily("plans", "timeout") }, + now, null, ct); + using (var with = await HealthAsync(postgres, server, ct)) + { + var entries = with.RootElement.GetProperty("stored_caveats").EnumerateArray().ToArray(); + Assert.Equal(new[] { "plans", "waits" }, entries.Select(x => x.GetProperty("family").GetString()).ToArray()); + Assert.Equal(new[] { "timeout", "missing_schema" }, entries.Select(x => x.GetProperty("reason").GetString()).ToArray()); + var first = DateTime.Parse(entries[0].GetProperty("first_seen_utc").GetString()!, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind); + var last = DateTime.Parse(entries[0].GetProperty("last_seen_utc").GetString()!, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind); + Assert.True(last - first > TimeSpan.FromHours(1.9), "Since keeps the first sighting and Last seen takes the newest"); + } + + // Another server's rows are not this server's: B has its own caveat, and each side sees only its own. + const int otherId = 480_142; + const string otherServer = "caveat-health-b"; + await using (var registerB = new NpgsqlCommand( + """ + INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major_version, created_date, modified_date) + VALUES ($1, $2, $2, TRUE, 15, $3, $3) + """, connection)) + { + registerB.Parameters.AddWithValue(otherId); + registerB.Parameters.AddWithValue(otherServer); + registerB.Parameters.AddWithValue(DateTime.SpecifyKind(now, DateTimeKind.Unspecified)); + await registerB.ExecuteNonQueryAsync(ct); + } + + await using (var logB = new NpgsqlCommand( + """ + INSERT INTO collection_log (log_id, collection_time, server_id, server_name, collector_name, status, duration_ms, rows_collected) + VALUES ($1, $2, $3, $4, 'wait_stats', 'SUCCESS', 120, 10) + """, connection)) + { + logB.Parameters.AddWithValue(CollectionIdGenerator.Next()); + logB.Parameters.AddWithValue(DateTime.SpecifyKind(now.AddMinutes(-1), DateTimeKind.Unspecified)); + logB.Parameters.AddWithValue(otherId); + logB.Parameters.AddWithValue(otherServer); + await logB.ExecuteNonQueryAsync(ct); + } + + await CollectionCaveatStore.ApplyPassAsync( + postgres, otherId, + new[] { new CollectionCaveatStore.UnreadFamily("locks", "timeout") }, + now, null, ct); + using (var a = await HealthAsync(postgres, server, ct)) + { + Assert.Equal(new[] { "plans", "waits" }, a.RootElement.GetProperty("stored_caveats").EnumerateArray().Select(x => x.GetProperty("family").GetString()).ToArray()); + } + + using (var b = await HealthAsync(postgres, otherServer, ct)) + { + var only = Assert.Single(b.RootElement.GetProperty("stored_caveats").EnumerateArray()); + Assert.Equal("locks", only.GetProperty("family").GetString()); + } + + // A store below V141 has no table: no caveats, and the health read still answers. + await using (var drop = new NpgsqlCommand("DROP TABLE collect.analysis_collection_caveats", connection)) + { + await drop.ExecuteNonQueryAsync(ct); + } + + using (var old = await HealthAsync(postgres, server, ct)) + { + Assert.False(old.RootElement.TryGetProperty("stored_caveats", out _)); + Assert.True(old.RootElement.GetProperty("collectors").GetArrayLength() > 0); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } +} diff --git a/Darling/Darling.Tests/CollectionGapAtStartTests.cs b/Darling/Darling.Tests/CollectionGapAtStartTests.cs new file mode 100644 index 0000000000..73de1f0584 --- /dev/null +++ b/Darling/Darling.Tests/CollectionGapAtStartTests.cs @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Notifications; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5450: at start, one "Collection Gap At Start" alert when the newest pre-start collection is 15 minutes or +/// more before the start. The evaluator holds the gate; the live test reads the newest time with the real SQL. +/// +/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. The live test reaches DARLING_TEST_PG only to + CREATE and DROP its own database through ScratchPostgres and works entirely inside it. */ +[Collection("gap-cache-serial")] +public sealed class CollectionGapAtStartTests +{ + private static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + private static readonly DateTime Start = new(2026, 10, 6, 12, 0, 0, DateTimeKind.Utc); + + private static DarlingSelfAlertEvaluator.CollectionGapReport Report(int minutesBeforeStart) => + new(Start.AddMinutes(-minutesBeforeStart), Start); + + [Fact] + public async Task ThirtyMinuteGap_FiresOnce_WithTheMessageAndTheValues() + { + var h = new DarlingSelfAlertTests.Harness(); + var e = h.Build(); + + await e.EvaluateCollectionGapAtStartAsync(Report(30), Ct); + + var fired = Assert.Single(h.Deliverer.Outcomes); + Assert.Equal("Collection Gap At Start", fired.MetricName); + Assert.Equal(AlertSeverityLevel.Warning, fired.Severity); + Assert.Equal("collectiongapatstart", fired.ServerKey); + Assert.Equal( + "Darling was not collecting from 2026-10-06 11:30 to 2026-10-06 12:00 UTC (30 minutes). " + + "Nothing was collected for any server in that window. " + + "If this was not a planned stop, check why the service or its host was down.", + fired.DetailText); + Assert.Equal(30, fired.NumericCurrentValue); + Assert.Equal(15, fired.NumericThresholdValue); + } + + [Fact] + public async Task ExactlyFifteenMinutes_Fires_AndFourteen_DoesNot() + { + var h = new DarlingSelfAlertTests.Harness(); + var e = h.Build(); + + await e.EvaluateCollectionGapAtStartAsync(Report(14), Ct); + Assert.Empty(h.Deliverer.Outcomes); + + await e.EvaluateCollectionGapAtStartAsync(Report(15), Ct); + Assert.Single(h.Deliverer.Outcomes); + } + + [Fact] + public async Task FiveMinuteGap_FiresNothing() + { + var h = new DarlingSelfAlertTests.Harness(); + var e = h.Build(); + + await e.EvaluateCollectionGapAtStartAsync(Report(5), Ct); + + Assert.Empty(h.Deliverer.Outcomes); + } + + [Fact] + public async Task EmptyStore_FiresNothing() + { + var h = new DarlingSelfAlertTests.Harness(); + var e = h.Build(); + + await e.EvaluateCollectionGapAtStartAsync(null, Ct); + + Assert.Empty(h.Deliverer.Outcomes); + } + + [Fact] + public async Task AlertsDisabled_FiresNothing() + { + var h = new DarlingSelfAlertTests.Harness(); + h.Settings.AlertsEnabled = false; + var e = h.Build(); + + await e.EvaluateCollectionGapAtStartAsync(Report(30), Ct); + + Assert.Empty(h.Deliverer.Outcomes); + } + + [Fact] + public void TheMetric_IsSelfMonitorFamily_Warning_AndNumeric() + { + Assert.Equal("Collection Gap At Start", DarlingSelfAlertEvaluator.CollectionGapAtStartMetric); + Assert.Equal(AlertFamily.SelfMonitor, AlertFamily.Of(DarlingSelfAlertEvaluator.CollectionGapAtStartMetric)); + Assert.Equal("WARNING", AlertSeverity.ForMetric(DarlingSelfAlertEvaluator.CollectionGapAtStartMetric).BadgeText); + /* A measurement (minutes), so never the state-only dash. */ + Assert.False(PerformanceMonitor.Common.AlertMetricClassifier.IsStateOnly(DarlingSelfAlertEvaluator.CollectionGapAtStartMetric)); + Assert.Equal(15, DarlingSelfAlertEvaluator.CollectionGapAtStartThreshold.TotalMinutes); + } + + [Fact] + public async Task GapIsMeasuredToTheProcessStart_NotToTheEndOfALongMigration() + { + /* The report's StartUtc is the process start. A migration that then ran for 20 minutes does not + enter the measure: 10 minutes from the last collection to the process start fires nothing. */ + var h = new DarlingSelfAlertTests.Harness(); + var e = h.Build(); + var processStart = Start; + var report = new DarlingSelfAlertEvaluator.CollectionGapReport(processStart.AddMinutes(-10), processStart); + _ = processStart.AddMinutes(20); /* migrations finished here; the old code measured to this */ + await e.EvaluateCollectionGapAtStartAsync(report, Ct); + Assert.Empty(h.Deliverer.Outcomes); + + /* A negative gap (a row newer than the start) fires nothing either. */ + await e.EvaluateCollectionGapAtStartAsync(new DarlingSelfAlertEvaluator.CollectionGapReport(processStart.AddMinutes(5), processStart), Ct); + Assert.Empty(h.Deliverer.Outcomes); + } + + private static async Task<(ScratchPostgres Scratch, NpgsqlConnection Connection)> OpenStoreAsync(string baseCs, bool hypertable) + { + var scratch = await ScratchPostgres.CreateAsync(baseCs, Ct); + var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(Ct); + await PgMigrations.MigrateAsync(connection, Ct); + if (!hypertable) + { + /* The migration makes a hypertable when the cluster has TimescaleDB. Swap in an ordinary table with + the one shipped index, so the plan is the one a store without TimescaleDB gets. */ + await ExecAsync(connection, @" +DROP TABLE collect.collection_log CASCADE; +CREATE TABLE collect.collection_log (log_id bigint NOT NULL, server_id integer NOT NULL, server_name text, + collector_name text, collection_time timestamp NOT NULL, status text); +CREATE INDEX idx_collection_log_time ON collect.collection_log(server_id, collection_time);"); + } + + if (hypertable) + { + Assert.True(await TimescaleSupport.TryEnableAsync(connection, null, Ct), "TimescaleDB must be enabled on the test cluster"); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, Ct); + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, Ct)); + } + + return (scratch, connection); + } + + private static async Task AddServerAsync(NpgsqlConnection connection, int id, bool enabled) => + await ExecAsync(connection, + "INSERT INTO config.config_monitored_servers (server_id, name, host, is_enabled) VALUES (@id, @n, 'h', @e)", + new NpgsqlParameter("id", id), new NpgsqlParameter("n", "gap-" + id), new NpgsqlParameter("e", enabled)); + + private static async Task SeedAsync(NpgsqlConnection connection, int serverId, DateTime newest, int days) + { + var u = DateTime.SpecifyKind(newest, DateTimeKind.Unspecified); + await ExecAsync(connection, @" +INSERT INTO collect.collection_log (log_id, server_id, server_name, collector_name, collection_time, status) +SELECT (@sid::bigint * 1000000) + row_number() OVER (ORDER BY t), @sid, 'gap-host', 'cpu', t, 'SUCCESS' +FROM generate_series(@newest - make_interval(days => @days), @newest, INTERVAL '1 hour') AS t", + new NpgsqlParameter("sid", serverId), + new NpgsqlParameter("days", days), + new NpgsqlParameter("newest", NpgsqlDbType.Timestamp) { Value = u }); + } + + private static async Task ExplainJsonAsync(NpgsqlConnection connection) + { + await using var explain = new NpgsqlCommand("EXPLAIN (FORMAT JSON) " + DarlingSelfAlertEvaluator.NewestCollectionTimeSql, connection); + return Convert.ToString(await explain.ExecuteScalarAsync(Ct), CultureInfo.InvariantCulture)!; + } + + private static void CollectScans(System.Text.Json.JsonElement e, List scans) + { + if (e.ValueKind == System.Text.Json.JsonValueKind.Object) + { + if (e.TryGetProperty("Node Type", out var t) && t.GetString()!.EndsWith("Scan", StringComparison.Ordinal)) + { + scans.Add(t.GetString() + ": " + (e.TryGetProperty("Relation Name", out var r) ? r.GetString() : "")); + } + + foreach (var p in e.EnumerateObject()) + { + CollectScans(p.Value, scans); + } + } + else if (e.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var x in e.EnumerateArray()) + { + CollectScans(x, scans); + } + } + } + + private static string? Env() => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + /// + /// Now, cut to whole microseconds. collection_log.collection_time is a PostgreSQL timestamp, which keeps + /// microseconds, while carries 100 ns ticks on Windows; an exact equality on the read-back + /// value would otherwise differ in the last digit. + /// + private static DateTime UtcNowToTheMicrosecond() + { + var now = DateTime.UtcNow; + return new DateTime(now.Ticks - (now.Ticks % 10), DateTimeKind.Utc); + } + + [Fact] + public async Task NoEnabledServer_ReadsNull_SoNothingFires() + { + Assert.SkipWhen(string.IsNullOrEmpty(Env()), "Set DARLING_TEST_PG to run the #5450 live tests."); + var ok = false; + var (scratch, connection) = await OpenStoreAsync(Env()!, hypertable: false); + try + { + /* A store with no servers at all (first install) reads null. */ + Assert.Null(await ScalarAsync(connection, DarlingSelfAlertEvaluator.NewestCollectionTimeSql)); + await AddServerAsync(connection, 7, enabled: false); + await AddServerAsync(connection, 8, enabled: false); + await SeedAsync(connection, 7, DateTime.UtcNow.AddMinutes(-40), 2); + Assert.Null(await ScalarAsync(connection, DarlingSelfAlertEvaluator.NewestCollectionTimeSql)); + ok = true; + } + finally + { + await connection.DisposeAsync(); + await LiveStoreCleanup.RunOwnedAsync(ok, async () => await scratch.DisposeAsync()); + } + } + + [Fact] + public async Task DisabledServersNewerRows_AndTheFleetSentinel_DoNotHideTheGap() + { + Assert.SkipWhen(string.IsNullOrEmpty(Env()), "Set DARLING_TEST_PG to run the #5450 live tests."); + var ok = false; + var (scratch, connection) = await OpenStoreAsync(Env()!, hypertable: false); + try + { + var now = UtcNowToTheMicrosecond(); + var newest = DateTime.SpecifyKind(now.AddMinutes(-40), DateTimeKind.Unspecified); + await AddServerAsync(connection, 7, enabled: true); + await AddServerAsync(connection, 8, enabled: false); + await SeedAsync(connection, 7, now.AddMinutes(-40), 1); + await SeedAsync(connection, 8, now.AddMinutes(-1), 1); + await SeedAsync(connection, 0, now.AddMinutes(-2), 1); + var read = Assert.IsType(await ScalarAsync(connection, DarlingSelfAlertEvaluator.NewestCollectionTimeSql)); + Assert.Equal(newest, read); + ok = true; + } + finally + { + await connection.DisposeAsync(); + await LiveStoreCleanup.RunOwnedAsync(ok, async () => await scratch.DisposeAsync()); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task NewestCollectionTimeSql_IsOneBackwardIndexProbePerServer_NeverASeqScan(bool hypertable) + { + Assert.SkipWhen(string.IsNullOrEmpty(Env()), "Set DARLING_TEST_PG to run the #5450 live tests."); + var ok = false; + var (scratch, connection) = await OpenStoreAsync(Env()!, hypertable); + try + { + var now = UtcNowToTheMicrosecond(); + var newest = DateTime.SpecifyKind(now.AddMinutes(-40), DateTimeKind.Unspecified); + foreach (var id in new[] { 7, 8, 9 }) + { + await AddServerAsync(connection, id, enabled: true); + await SeedAsync(connection, id, now.AddMinutes(-40 - id), 35); + } + + await SeedAsync(connection, 7, now.AddMinutes(-40), 0); + await ExecAsync(connection, "ANALYZE collect.collection_log"); + Assert.Equal(newest, Assert.IsType(await ScalarAsync(connection, DarlingSelfAlertEvaluator.NewestCollectionTimeSql))); + + /* A plain-PostgreSQL scratch database need not carry the TimescaleDB extension at all (CI's does not), + so its information views are read only where they exist; absent, collection_log is plainly not a + hypertable. */ + var timescaleViews = await ScalarAsync(connection, "SELECT to_regclass('timescaledb_information.hypertables') IS NOT NULL") is true; + var chunks = timescaleViews + ? Convert.ToInt32(await ScalarAsync(connection, + "SELECT count(*) FROM timescaledb_information.chunks WHERE hypertable_name = 'collection_log'"), CultureInfo.InvariantCulture) + : 0; + var isHypertable = timescaleViews + ? Convert.ToInt32(await ScalarAsync(connection, + "SELECT count(*) FROM timescaledb_information.hypertables WHERE hypertable_name = 'collection_log'"), CultureInfo.InvariantCulture) + : 0; + Assert.Equal(hypertable ? 1 : 0, isHypertable); + if (hypertable) + { + Assert.True(chunks >= 4, $"seed should span several chunks, saw {chunks}"); + } + + var plan = await ExplainJsonAsync(connection); + var scansSeen = new List(); + Console.WriteLine("PLAN5450 hypertable=" + hypertable + " chunks=" + chunks + "\n" + plan); + /* The three-row server list may be seq-scanned; collection_log (or any chunk of it) never. */ + using (var doc = System.Text.Json.JsonDocument.Parse(plan)) + { + CollectScans(doc.RootElement, scansSeen); + Assert.DoesNotContain(scansSeen, x => x.StartsWith("Seq Scan:", StringComparison.Ordinal) && !x.Contains("config_monitored_servers", StringComparison.Ordinal)); + } + + Assert.Contains(scansSeen, x => x.StartsWith("Index Only Scan:", StringComparison.Ordinal) || x.StartsWith("Index Scan:", StringComparison.Ordinal)); + Assert.Contains("Backward", plan, StringComparison.Ordinal); + if (!hypertable) + { + Assert.Contains("idx_collection_log_time", plan, StringComparison.Ordinal); + } + + ok = true; + } + finally + { + await connection.DisposeAsync(); + await LiveStoreCleanup.RunOwnedAsync(ok, async () => await scratch.DisposeAsync()); + } + } + + private static async Task ScalarAsync(NpgsqlConnection connection, string sql) + { + await using var command = new NpgsqlCommand(sql, connection); + var value = await command.ExecuteScalarAsync(Ct); + return value is DBNull ? null : value; + } + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, params NpgsqlParameter[] parameters) + { + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.AddRange(parameters); + await command.ExecuteNonQueryAsync(Ct); + } +} diff --git a/Darling/Darling.Tests/CollectionHealthDetachedSweepPressureTests.cs b/Darling/Darling.Tests/CollectionHealthDetachedSweepPressureTests.cs new file mode 100644 index 0000000000..0bfedc2a5f --- /dev/null +++ b/Darling/Darling.Tests/CollectionHealthDetachedSweepPressureTests.cs @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4999 (part of #4938): get_collection_health's sweep-pressure roll-up compares the collectors' single-run +/// costs with the one 60-second body their server's sequential pass has to fit in, so it may count only the +/// collectors that run IN that pass. A collector that runs detached does not: it runs beside the pass, and a +/// long p95 on it said BODY_OVERRUN for a body that never waited on it (index_object_stats, once a day, naming +/// itself as the collector that owns the body). +/// +/// The decision is , which the worker's dispatch also asks, so the +/// two cannot disagree about what runs in the pass. The source pins below are what keep it that way: a second, +/// hand-copied version of the test in either file is a drift the behavioural cases cannot see. +/// +public sealed class CollectionHealthDetachedSweepPressureTests +{ + private const string Daily = "index_object_stats"; + private const string OneMinute = "wait_stats"; + + /// One run costs more than the whole 60,000 ms body, so a collector counted in the body reads BODY_OVERRUN. + private const double LongP95Ms = 90_000; + + private const string WorkerPath = "Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"; + private const string ToolsPath = "Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs"; + + private static CollectorHealth Row(string name, double avgMs, double p95Ms) => + new() { CollectorName = name, AvgDurationMs = avgMs, P95DurationMs = p95Ms }; + + private static SweepPressure Rollup(params CollectorHealth[] rows) => + SweepPressureClassifier.Compute(DarlingMcpDataTools.SweepBodyCollectors(rows)); + + [Fact] + public void ADailyCollectorWithALongP95_IsNotChargedToTheSweepBody() + { + var rows = new[] + { + Row(Daily, avgMs: 40_000, p95Ms: LongP95Ms), + Row(OneMinute, avgMs: 50, p95Ms: 100), + }; + + /* The fixture is a real overrun when the daily collector IS counted: this is what the tool said before. */ + var counted = SweepPressureClassifier.Compute( + rows.Select(r => (r.CollectorName, r.AvgDurationMs, r.P95DurationMs, r.FrequencyMinutes))); + Assert.Equal(SweepPressureClassifier.PeakCycleBodyOverrun, counted.PeakCycleRisk); + Assert.Equal(Daily, counted.PeakCollectorName); + + var pressure = Rollup(rows); + Assert.Equal(SweepPressureClassifier.PeakCycleFits, pressure.PeakCycleRisk); + Assert.Equal(OneMinute, pressure.PeakCollectorName); + } + + [Fact] + public void AOneMinuteCollectorWithTheSameP95_StillReadsBodyOverrun() + { + Assert.Equal(1, CollectorScheduleDefaults.All[OneMinute].FrequencyMinutes); + + var pressure = Rollup( + Row(OneMinute, avgMs: 40_000, p95Ms: LongP95Ms), + Row(Daily, avgMs: 50, p95Ms: 100)); + + Assert.Equal(SweepPressureClassifier.PeakCycleBodyOverrun, pressure.PeakCycleRisk); + Assert.Equal(OneMinute, pressure.PeakCollectorName); + } + + /// + /// The three that were detached before the daily ones (by name, on a five-minute cadence) and one of each + /// kind of daily collector: a catalog 1440-minute one, and an on-load one whose catalog 0 is its daily + /// recapture. None of them runs in the pass, so none is charged to it. + /// + [Theory] + [InlineData("query_store")] + [InlineData("plan_correction")] + [InlineData("pg_wait_sampling")] + [InlineData("index_object_stats")] + [InlineData("pg_index_bloat")] + [InlineData("server_config")] + public void ACollectorThatRunsDetached_IsLeftOutOfTheRollUp(string name) + { + Assert.True(CollectorScheduleDefaults.All.ContainsKey(name), $"'{name}' is not in the catalog"); + + var pressure = Rollup(Row(name, avgMs: 40_000, p95Ms: LongP95Ms)); + + Assert.Equal(SweepPressureClassifier.PeakCycleFits, pressure.PeakCycleRisk); + Assert.Null(pressure.PeakCollectorName); + Assert.Equal(0, pressure.BusyMsPerMinute); + } + + /// + /// Over the whole catalog, from the catalog's own cadences rather than from the predicate under test: a row + /// is left out of the roll-up exactly when its collector is detached by name or runs at a daily cadence or + /// slower, and kept otherwise. + /// + [Fact] + public void TheRollUpKeepsExactlyTheCollectorsThatRunInThePass() + { + foreach (var (name, schedule) in CollectorScheduleDefaults.All) + { + var byName = name is "query_store" or "plan_correction" or "pg_wait_sampling"; + var effective = CollectorScheduleDefaults.EffectiveRecurringIntervalMinutes(schedule.FrequencyMinutes); + var expectedInPass = !byName && effective < 1440; + + var kept = DarlingMcpDataTools.SweepBodyRows(new[] { Row(name, 1, 1) }).Any(); + + Assert.True( + expectedInPass == kept, + $"{name} (every {effective} min, detached by name: {byName}): in the pass = {expectedInPass}, kept in the roll-up = {kept}"); + } + } + + /// + /// The worker's dispatch asks RunsDetached, and this tool asks it too, with no second copy of the test + /// on either side. A hand-copied version passes every behavioural case above until the day one copy is + /// edited, which is the drift this pin exists to refuse. + /// + [Fact] + public void TheWorkersDispatchAndTheHealthToolAskTheSameTest() + { + var worker = ReadRepoFile(WorkerPath); + var tools = ReadRepoFile(ToolsPath); + + /* The test is defined once, from its two halves. */ + Assert.Contains( + "IsDetachedByName(name) || IsDailyInterval(effectiveIntervalMinutes)", worker, StringComparison.Ordinal); + Assert.Equal(1, Count(worker, "internal static bool RunsDetached(")); + + /* The dispatch decides with it. */ + Assert.Equal(1, Count(worker, "if (!RunsDetached(name, interval))")); + + /* The three by-name predicates are chained in ONE place, IsDetachedByName, and nowhere else in the worker + under the dispatch's variable name: a copy of the chain in the dispatch is the second copy. */ + Assert.Equal(1, Count(worker, "IsQueryStoreCollector(name) || IsPlanCorrectionCollector(name) || IsPgWaitSamplingCollector(name)")); + + /* The tool asks it through the worker, once, and does not name any piece of it. */ + Assert.Equal(1, Count(tools, "DarlingWorker.RunsDetached(")); + foreach (var piece in new[] + { + "IsQueryStoreCollector", "IsPlanCorrectionCollector", "IsPgWaitSamplingCollector", "IsDailyInterval", + "IsDetachedByName", "DailyCollectorIntervalMinutes", + }) + { + Assert.DoesNotContain(piece, tools, StringComparison.Ordinal); + } + + /* Both readings of the body's population, the roll-up and the heaviest list, come through the one filter. */ + Assert.Contains( + "var pressure = SweepPressureClassifier.Compute(SweepBodyCollectors(rows));", tools, StringComparison.Ordinal); + Assert.Contains("var heaviest = SweepBodyRows(rows)", tools, StringComparison.Ordinal); + } + + private static int Count(string haystack, string needle) + { + var count = 0; + for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; + i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } +} diff --git a/Darling/Darling.Tests/CollectionHealthEffectiveCadenceTests.cs b/Darling/Darling.Tests/CollectionHealthEffectiveCadenceTests.cs new file mode 100644 index 0000000000..2c1d75ad95 --- /dev/null +++ b/Darling/Darling.Tests/CollectionHealthEffectiveCadenceTests.cs @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4999 (part of #4938): get_collection_health judges a collector against the interval it is SCHEDULED at on the +/// server, not the one it shipped with. A collector an operator moved to every 720 minutes was banded against its +/// shipped five, and a collector an override moved across the daily line stayed in (or out of) the sweep-pressure +/// roll-up by its shipped cadence while the worker's dispatch had already moved it. +/// +/// The resolution is the worker's own, : a per-server +/// override, else the fleet-wide one, else the shipped default. The tests apply a set of overrides to rows with +/// , the step the memoized health read runs on every row it +/// builds, so no store is needed. +/// +public sealed class CollectionHealthEffectiveCadenceTests +{ + private const string FiveMinute = "memory_clerks"; + private const string Daily = "index_object_stats"; + private const int ServerId = 7; + private const int OtherServerId = 8; + + private const string ReaderPath = "Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingDataReader.cs"; + private const string WorkerPath = "Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"; + + private static CollectorHealth Row(string name, double avgMs = 0, double p95Ms = 0) => + new() { CollectorName = name, AvgDurationMs = avgMs, P95DurationMs = p95Ms }; + + private static ScheduleOverride Override(int? serverId, string name, int? minutes) => + new(serverId, name, minutes, RetentionDays: null, Enabled: true); + + private static void Apply(CollectorHealth row, params ScheduleOverride[] overrides) => + DarlingDataReader.ApplyScheduledFrequencies(new[] { row }, ServerId, overrides); + + /// A row that bands HEALTHY on any cadence while its newest success is recent enough. + private static CollectorHealth ProductiveRow(string name, double hoursSinceLastSuccess) + { + var last = DateTime.UtcNow.AddHours(-hoursSinceLastSuccess); + return new CollectorHealth + { + CollectorName = name, + TotalRuns = 10, + SuccessCount = 10, + RowsStored = 100, + RunsWithRows = 10, + LastSuccessTime = last, + LastRunTime = last, + }; + } + + [Fact] + public void AnOverrideOf720OnAFiveMinuteCollector_IsJudgedAgainst720() + { + Assert.Equal(5, CollectorScheduleDefaults.All[FiveMinute].FrequencyMinutes); + + var row = Row(FiveMinute, avgMs: 30_000, p95Ms: 30_000); + Assert.Equal(5, row.FrequencyMinutes); + + Apply(row, Override(ServerId, FiveMinute, 720)); + + Assert.Equal(720, row.FrequencyMinutes); + + /* The roll-up amortises the run cost by the interval the row carries: 30,000 ms every 720 minutes. */ + var pressure = SweepPressureClassifier.Compute(DarlingMcpDataTools.SweepBodyCollectors(new[] { row })); + Assert.Equal(30_000.0 / 720, pressure.BusyMsPerMinute, 3); + Assert.Equal(720, pressure.PeakCollectorFrequencyMinutes); + } + + [Fact] + public void TheBandJudgesAgainstTheScheduledInterval_NotTheShippedOne() + { + /* Six hours since the newest success: past the four-hour floor a five-minute collector goes STALE at, well + inside the 18 hours (one and a half intervals) a 720-minute collector gets. */ + var shipped = ProductiveRow(FiveMinute, hoursSinceLastSuccess: 6); + Assert.Equal(CollectorHealthClassifier.Stale, shipped.HealthStatus); + + var scheduled = ProductiveRow(FiveMinute, hoursSinceLastSuccess: 6); + Apply(scheduled, Override(ServerId, FiveMinute, 720)); + Assert.Equal(CollectorHealthClassifier.Healthy, scheduled.HealthStatus); + } + + [Fact] + public void APerServerOverrideBeatsTheFleetOne_AndAnotherServersIsIgnored() + { + var fleetOnly = Row(FiveMinute); + Apply(fleetOnly, Override(null, FiveMinute, 720)); + Assert.Equal(720, fleetOnly.FrequencyMinutes); + + var both = Row(FiveMinute); + Apply(both, Override(null, FiveMinute, 720), Override(ServerId, FiveMinute, 60)); + Assert.Equal(60, both.FrequencyMinutes); + + var elsewhere = Row(FiveMinute); + Apply(elsewhere, Override(OtherServerId, FiveMinute, 720)); + Assert.Equal(5, elsewhere.FrequencyMinutes); + } + + [Fact] + public void AnOverrideThatMovesACollectorAcrossTheDailyLine_MovesItInTheRollUpToo() + { + const double LongP95Ms = 90_000; + + /* A five-minute collector scheduled daily runs beside the pass: its long run is not charged to the body. */ + var movedOut = Row(FiveMinute, avgMs: 40_000, p95Ms: LongP95Ms); + Assert.Equal( + SweepPressureClassifier.PeakCycleBodyOverrun, + SweepPressureClassifier.Compute(DarlingMcpDataTools.SweepBodyCollectors(new[] { movedOut })).PeakCycleRisk); + Apply(movedOut, Override(ServerId, FiveMinute, 1440)); + var outside = SweepPressureClassifier.Compute(DarlingMcpDataTools.SweepBodyCollectors(new[] { movedOut })); + Assert.Equal(SweepPressureClassifier.PeakCycleFits, outside.PeakCycleRisk); + Assert.Null(outside.PeakCollectorName); + + /* The reverse: a daily collector scheduled every five minutes runs in the pass, so its long run is. */ + var movedIn = Row(Daily, avgMs: 40_000, p95Ms: LongP95Ms); + Assert.Equal( + SweepPressureClassifier.PeakCycleFits, + SweepPressureClassifier.Compute(DarlingMcpDataTools.SweepBodyCollectors(new[] { movedIn })).PeakCycleRisk); + Apply(movedIn, Override(ServerId, Daily, 5)); + var inside = SweepPressureClassifier.Compute(DarlingMcpDataTools.SweepBodyCollectors(new[] { movedIn })); + Assert.Equal(SweepPressureClassifier.PeakCycleBodyOverrun, inside.PeakCycleRisk); + Assert.Equal(Daily, inside.PeakCollectorName); + } + + [Fact] + public void WithNoOverrideARowKeepsItsShippedCadence_AndAnUnknownNameIsLeftAlone() + { + var known = Row(FiveMinute); + Apply(known); + Assert.Equal(5, known.FrequencyMinutes); + + /* An on-load collector's catalog 0 is its daily recapture, the substitution the dispatch makes. */ + var onLoad = Row("server_config"); + Apply(onLoad); + Assert.Equal(CollectorScheduleDefaults.OnLoadRecaptureMinutes, onLoad.FrequencyMinutes); + + /* A name the catalog does not know would throw in ResolveSchedule; it stays unstamped at 0. */ + var unknown = Row("not_a_collector"); + Apply(unknown, Override(ServerId, "not_a_collector", 720)); + Assert.Null(unknown.EffectiveFrequencyMinutes); + Assert.Equal(0, unknown.FrequencyMinutes); + } + + /// + /// The resolution is the worker's own call and not a copy of it, and it runs inside the memoized read: the + /// memo hands one list to every caller of the minute, so a stamp applied after it returned would mutate rows + /// other callers are enumerating. + /// + [Fact] + public void TheReadResolvesThroughTheWorkersCall_InsideTheMemoizedRead() + { + var reader = ReadRepoFile(ReaderPath); + var worker = ReadRepoFile(WorkerPath); + var tools = ReadRepoFile("Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpDataTools.cs"); + + Assert.Contains("StoreConfigProvider.ResolveSchedule(name, runtime.ServerId, _scheduleOverrides)", worker, StringComparison.Ordinal); + Assert.Equal(1, Count(reader, "StoreConfigProvider.ResolveSchedule(row.CollectorName, serverId, overrides)")); + + /* One stamp, in the method that builds the rows, ahead of its return. */ + Assert.Equal(1, Count(reader, "ApplyScheduledFrequencies(rows, serverId, scheduleOverrides);")); + Assert.True( + reader.IndexOf("ApplyScheduledFrequencies(rows, serverId, scheduleOverrides);", StringComparison.Ordinal) + < reader.IndexOf("internal const string ScheduleOverridesSql", StringComparison.Ordinal)); + + /* The tool itself neither stamps nor resolves: it reads the row's interval. */ + Assert.DoesNotContain("ApplyScheduledFrequencies", tools, StringComparison.Ordinal); + Assert.DoesNotContain("ResolveSchedule", tools, StringComparison.Ordinal); + + /* The overrides come from the sparse schedule table, schema-qualified, this server's rows and the fleet's. */ + Assert.Contains("FROM config.config_collector_schedules", DarlingDataReader.ScheduleOverridesSql, StringComparison.Ordinal); + Assert.Contains("server_id IS NULL", DarlingDataReader.ScheduleOverridesSql, StringComparison.Ordinal); + } + + private static int Count(string haystack, string needle) + { + var count = 0; + for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; + i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } +} diff --git a/Darling/Darling.Tests/CollectionHealthEverySurfaceEffectiveIntervalTests.cs b/Darling/Darling.Tests/CollectionHealthEverySurfaceEffectiveIntervalTests.cs new file mode 100644 index 0000000000..af5071e8b5 --- /dev/null +++ b/Darling/Darling.Tests/CollectionHealthEverySurfaceEffectiveIntervalTests.cs @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Viewer; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4999 (part of #4938): every collection-health surface judges a collector against the interval it is SCHEDULED at on +/// its server, not the one it shipped with. get_collection_health did first; the fleet roll-up (the per-server +/// healthy and failing counts of the fleet overview) and the viewer's collection-health reads (a server's own tab, +/// and the Overview cards and status bar) still judged against the shipped interval, so a collector an operator +/// slowed to every 720 minutes could band HEALTHY in one place and STALE in another. +/// +/// The rule is one: in +/// PerformanceMonitor.Collectors, the project the service and the viewer both reference. The worker's +/// picks its rows through the same +/// . The cases below pin that the three are the +/// same answer, then drive each surface's own step with a row whose newest success is six hours old: past the +/// four-hour line a five-minute collector goes STALE at, well inside the 18 hours (one and a half intervals) a +/// collector scheduled every 720 minutes gets. +/// +public sealed class CollectionHealthEverySurfaceEffectiveIntervalTests +{ + private const string FiveMinute = "memory_clerks"; + private const int ServerId = 7; + private const int OtherServerId = 8; + + private const string FleetReaderPath = "Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingFleetReader.cs"; + private const string ViewerPath = "Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.CollectionHealth.cs"; + private const string StoreConfigPath = "Darling/PerformanceMonitor.Darling.Service/StoreConfigProvider.cs"; + + private static ScheduleOverride Override(int? serverId, string name, int? minutes) => + new(serverId, name, minutes, RetentionDays: null, Enabled: true); + + private static int? Resolve(string name, params ScheduleOverride[] rows) => + CollectorScheduleDefaults.ResolveEffectiveIntervalMinutes(name, ServerId, rows); + + private static CollectorScheduleRow ViewerOverride(int? serverId, string name, int? minutes) => + new(serverId, name, minutes, RetentionDays: null, Enabled: true); + + /// The same rows as the viewer carries them. + private static CollectorScheduleRow[] AsViewerRows(IEnumerable rows) => + rows.Select(r => ViewerOverride(r.ServerId, r.CollectorName, r.FrequencyMinutes)).ToArray(); + + /// One row in the fourteen-column shape both fleet reads produce (server_id first), healthy on every count + /// and with every instant hours old, so the only thing that can move the band is the + /// interval the row is judged against. + private static DataTableReader FleetRow(string collector, double hoursAgo) + { + var table = new DataTable(); + table.Columns.Add("server_id", typeof(int)); + table.Columns.Add("collector_name", typeof(string)); + foreach (var name in new[] { "total_runs", "success_count", "error_count" }) + { + table.Columns.Add(name, typeof(long)); + } + + table.Columns.Add("last_success_time", typeof(DateTime)); + table.Columns.Add("permission_denied_count", typeof(long)); + table.Columns.Add("last_run_time", typeof(DateTime)); + table.Columns.Add("abandoned_count", typeof(long)); + table.Columns.Add("extension_missing_count", typeof(long)); + table.Columns.Add("last_non_skip_time", typeof(DateTime)); + table.Columns.Add("last_productive_time", typeof(DateTime)); + table.Columns.Add("last_zero_row_streak_break_time", typeof(DateTime)); + table.Columns.Add("latest_run_note", typeof(string)); + + var then = DateTime.UtcNow.AddHours(-hoursAgo); + table.Rows.Add(ServerId, collector, 100L, 100L, 0L, then, 0L, then, 0L, 0L, then, then, then, DBNull.Value); + var reader = table.CreateDataReader(); + Assert.True(reader.Read()); + return reader; + } + + /* ───────────────────────── the one rule ───────────────────────── */ + + private static IEnumerable<(string Scenario, ScheduleOverride[] Rows)> Scenarios(string name) => + [ + ("no override", Array.Empty()), + ("fleet only", [Override(null, name, 720)]), + ("server only", [Override(ServerId, name, 60)]), + ("server beats fleet", [Override(null, name, 720), Override(ServerId, name, 60)]), + ("another server's row is ignored", [Override(OtherServerId, name, 720)]), + ("another server's row beside the fleet's", [Override(OtherServerId, name, 30), Override(null, name, 720)]), + ("a name in another case", [Override(ServerId, name.ToUpperInvariant(), 90)]), + ("a negative server row falls through to the fleet's", [Override(ServerId, name, -5), Override(null, name, 720)]), + ("a null frequency falls through", [Override(ServerId, name, null), Override(null, name, 720)]), + ("zero is honoured (on-load only)", [Override(ServerId, name, 0)]), + ("a delta-family cadence past its cap falls through", [Override(ServerId, name, 100_000), Override(null, name, 15)]), + ("another collector's row is ignored", [Override(ServerId, name + "_other", 720)]), + ]; + + /// + /// The shared rule gives, for every catalog collector under every layering of overrides, exactly what the worker + /// schedules the collector at: then + /// . The viewer carries the rows as its own + /// record and resolves them through the same rule, so it gives the same answer too. + /// + [Fact] + public void TheSharedResolution_GivesWhatTheWorkerSchedulesEveryCollectorAt_ForEveryLayeringOfOverrides() + { + var compared = 0; + foreach (var name in CollectorScheduleDefaults.All.Keys) + { + foreach (var (scenario, rows) in Scenarios(name)) + { + var worker = CollectorScheduleDefaults.EffectiveRecurringIntervalMinutes( + StoreConfigProvider.ResolveSchedule(name, ServerId, rows).FrequencyMinutes); + + Assert.True( + worker == CollectorScheduleDefaults.ResolveEffectiveIntervalMinutes(name, ServerId, rows), + $"{name}: {scenario}: the shared rule disagrees with the worker's schedule"); + Assert.True( + worker == CollectorScheduleDefaults.ResolveEffectiveIntervalMinutes(name, ServerId, AsViewerRows(rows)), + $"{name}: {scenario}: the viewer's rows resolve differently from the worker's"); + compared++; + } + } + + Assert.Equal(CollectorScheduleDefaults.All.Count * Scenarios(FiveMinute).Count(), compared); + } + + [Fact] + public void TheSharedResolution_PicksTheServerRow_ThenTheFleetRow_ThenTheShippedDefault() + { + Assert.Equal(5, CollectorScheduleDefaults.All[FiveMinute].FrequencyMinutes); + + Assert.Equal(5, Resolve(FiveMinute)); + Assert.Equal(720, Resolve(FiveMinute, Override(null, FiveMinute, 720))); + Assert.Equal(60, Resolve(FiveMinute, Override(null, FiveMinute, 720), Override(ServerId, FiveMinute, 60))); + Assert.Equal(5, Resolve(FiveMinute, Override(OtherServerId, FiveMinute, 720))); + Assert.Equal(5, CollectorScheduleDefaults.ResolveEffectiveIntervalMinutes(FiveMinute, ServerId, null)); + } + + [Fact] + public void AnOnLoadCollectorReadsAsItsDailyRecapture_AndAnUnknownNameHasNoInterval() + { + Assert.Equal(0, CollectorScheduleDefaults.All["server_config"].FrequencyMinutes); + Assert.Equal(CollectorScheduleDefaults.OnLoadRecaptureMinutes, Resolve("server_config")); + Assert.Equal(60, Resolve("server_config", Override(null, "server_config", 60))); + + Assert.Null(Resolve("not_a_collector", Override(null, "not_a_collector", 60))); + } + + /* ───────────────────────── each surface ───────────────────────── */ + + /// + /// The fleet roll-up: the same row it reads, mapped through the step the roll-up's loop calls with the server's + /// own schedule rows. Judged against the shipped five minutes it is STALE; against the 720 its server runs it at, + /// HEALTHY, as get_collection_health says for that server. + /// + [Fact] + public void TheFleetRollUp_BandsACollectorAgainstTheIntervalItsServerSchedulesItAt() + { + using (var shipped = FleetRow(FiveMinute, hoursAgo: 6)) + { + var row = DarlingFleetReader.MapFleetHealthRow(shipped); + Assert.Equal(5, row.FrequencyMinutes); + Assert.Equal(CollectorHealthClassifier.Stale, row.HealthStatus); + } + + using (var scheduled = FleetRow(FiveMinute, hoursAgo: 6)) + { + var row = DarlingFleetReader.MapFleetHealthRow(scheduled, ServerId, [Override(ServerId, FiveMinute, 720)]); + Assert.Equal(720, row.FrequencyMinutes); + Assert.Equal(CollectorHealthClassifier.Healthy, row.HealthStatus); + } + + /* The fleet-wide row reaches the server too, and another server's own row does not. */ + using (var fleetWide = FleetRow(FiveMinute, hoursAgo: 6)) + { + var row = DarlingFleetReader.MapFleetHealthRow(fleetWide, ServerId, [Override(null, FiveMinute, 720)]); + Assert.Equal(CollectorHealthClassifier.Healthy, row.HealthStatus); + } + + using (var elsewhere = FleetRow(FiveMinute, hoursAgo: 6)) + { + var row = DarlingFleetReader.MapFleetHealthRow(elsewhere, ServerId, [Override(OtherServerId, FiveMinute, 720)]); + Assert.Equal(5, row.FrequencyMinutes); + Assert.Equal(CollectorHealthClassifier.Stale, row.HealthStatus); + } + } + + /// + /// The viewer's Overview cards and status bar read the per-(server, collector) fleet breakdown, and its rows are + /// stamped by server, so the band they count is the one the server's own tab shows. + /// + [Fact] + public void TheViewersFleetBreakdown_BandsACollectorAgainstTheIntervalItsServerSchedulesItAt() + { + using var reader = FleetRow(FiveMinute, hoursAgo: 6); + var row = ViewerDataService.MapFleetByServerRow(reader); + Assert.Equal(CollectorHealthClassifier.Stale, row.HealthStatus); + + ViewerDataService.ApplyScheduledFrequencies([row], ServerId, [ViewerOverride(ServerId, FiveMinute, 720)]); + + Assert.Equal(720, row.EffectiveFrequencyMinutes); + Assert.Equal(CollectorHealthClassifier.Healthy, row.HealthStatus); + } + + /// The viewer's per-server Collection Health tab, with a per-server row beating the fleet-wide one. + [Fact] + public void TheViewersServerTab_BandsACollectorAgainstTheIntervalItIsScheduledAt_ServerRowFirst() + { + static CollectorHealthRow Row() + { + var then = DateTime.UtcNow.AddHours(-6); + return new CollectorHealthRow + { + CollectorName = FiveMinute, + TotalRuns = 100, + SuccessCount = 100, + LastSuccessTime = then, + LastRunTime = then, + LastNonSkipTime = then, + LastProductiveTime = then, + }; + } + + var shipped = Row(); + Assert.Equal(CollectorHealthClassifier.Stale, shipped.HealthStatus); + + var server = Row(); + ViewerDataService.ApplyScheduledFrequencies([server], ServerId, [ViewerOverride(ServerId, FiveMinute, 720)]); + Assert.Equal(720, server.EffectiveFrequencyMinutes); + Assert.Equal(CollectorHealthClassifier.Healthy, server.HealthStatus); + + /* The fleet row alone, then the server row over it: the server's 5 beats the fleet's 720. */ + var fleet = Row(); + ViewerDataService.ApplyScheduledFrequencies([fleet], ServerId, [ViewerOverride(null, FiveMinute, 720)]); + Assert.Equal(CollectorHealthClassifier.Healthy, fleet.HealthStatus); + + var both = Row(); + ViewerDataService.ApplyScheduledFrequencies( + [both], ServerId, [ViewerOverride(null, FiveMinute, 720), ViewerOverride(ServerId, FiveMinute, 5)]); + Assert.Equal(5, both.EffectiveFrequencyMinutes); + Assert.Equal(CollectorHealthClassifier.Stale, both.HealthStatus); + + /* A name the catalog does not know is left unstamped, keeping the classifier's floor thresholds. */ + var unknown = new CollectorHealthRow { CollectorName = "not_a_collector" }; + ViewerDataService.ApplyScheduledFrequencies([unknown], ServerId, [ViewerOverride(null, "not_a_collector", 720)]); + Assert.Null(unknown.EffectiveFrequencyMinutes); + } + + /* ───────────────────────── the wiring ───────────────────────── */ + + /// + /// The reads that need a store cannot be driven here, so what a case can pin is that each calls the step the cases + /// above drive, once, after its rows are built, and that nothing resolves a second way. + /// + [Fact] + public void EachSurfaceStampsThroughTheOneResolution_AndNoSurfaceResolvesItsOwn() + { + var fleet = ReadRepoFile(FleetReaderPath); + var viewer = ReadRepoFile(ViewerPath); + var storeConfig = ReadRepoFile(StoreConfigPath); + + /* The fleet roll-up reads the whole schedule table once, ahead of its health statement, and maps every row + with its server's rows. */ + Assert.Equal(1, Count(fleet, "DarlingDataReader.ReadScheduleOverridesAsync(postgres, null, cancellationToken)")); + Assert.Equal(1, Count(fleet, "MapFleetHealthRow(reader, serverId, serverOverrides)")); + Assert.True( + fleet.IndexOf("DarlingDataReader.ReadScheduleOverridesAsync(postgres, null, cancellationToken)", StringComparison.Ordinal) + < fleet.IndexOf("postgres.CreateCommand(composedSql ?? FleetCollectionHealthSql)", StringComparison.Ordinal)); + + /* The viewer stamps what its two row-building reads return, and resolves only through the shared rule. */ + Assert.Equal(1, Count(viewer, "ApplyScheduledFrequencies(items, serverId, scheduleOverrides);")); + Assert.Equal(1, Count(viewer, "ApplyScheduledFrequencies(rows, serverId, scheduleOverrides.Where(")); + Assert.Equal(1, Count(viewer, "CollectorScheduleDefaults.ResolveEffectiveIntervalMinutes(row.CollectorName, serverId, overrides)")); + Assert.DoesNotContain("ResolveFrequencyMinutes(", viewer, StringComparison.Ordinal); + + /* The worker's own resolution picks its rows through the shared selector, not a loop of its own. */ + Assert.Equal(1, Count(storeConfig, "CollectorScheduleDefaults.SelectScheduleOverrides(collectorName, serverId, overrides)")); + var resolve = storeConfig.IndexOf("public static EffectiveSchedule ResolveSchedule(", StringComparison.Ordinal); + var resolveEnd = storeConfig.IndexOf("ResolveFleetRetentionDays(", resolve, StringComparison.Ordinal); + Assert.DoesNotContain("foreach (var o in overrides)", storeConfig[resolve..resolveEnd], StringComparison.Ordinal); + } + + private static int Count(string haystack, string needle) + { + var count = 0; + for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0; + i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) + { + count++; + } + + return count; + } +} diff --git a/Darling/Darling.Tests/CollectionHealthKeyedLookupParityTests.cs b/Darling/Darling.Tests/CollectionHealthKeyedLookupParityTests.cs new file mode 100644 index 0000000000..547247a4ff --- /dev/null +++ b/Darling/Darling.Tests/CollectionHealthKeyedLookupParityTests.cs @@ -0,0 +1,501 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using NpgsqlTypes; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4955: the per-server collection-health statements (the MCP tool's and the viewer grid's) used to rank every +/// run of the seven-day window four (MCP) or three (viewer) times with ROW_NUMBER() only to read the +/// NEWEST row of a class, and now read those rows through keyed lookups instead. A source pin cannot tell a +/// lookup that picks the same row as the rank from one that picks a neighbour, so this class runs the pre-change +/// statements (, kept verbatim) beside the current ones over one seeded +/// store and requires every column of every row to agree - name, type and value - across the ties and the empty +/// classes where the two shapes could part: equal timestamps with different statuses and different texts, a +/// failure with text older than one without, notes that a run abandoned, a streak with and without a break, a +/// collector that only skipped, a collector with one run, fan-out rows tied on the slowest item, a status the +/// column cannot hold in the real table (a NULL one, seeded into a session-local copy of the view), and rows just +/// before the window. +/// +/// Where the oracle's own order is not total (two rows equal on every ORDER BY key and different in a +/// selected column) either row is a right answer, so no such case is seeded. +/// +/// Each live test mints its own scratch database, so the rows it seeds and the plans it explains are its own: +/// no other class's rows or chunks can reach them (#4650). +/// +/* #1776 own-store: each live test mints its own scratch database, so no other class's rows or chunks shape what it + reads or how its plans come out. */ +[Collection("live-postgres")] +public sealed class CollectionHealthKeyedLookupParityTests +{ + private const int ServerId = -4955001; + private const string ServerName = "keyed-lookup-parity"; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private static readonly Regex s_fullWindowRank = new( + @"ROW_NUMBER\(\)\s+OVER\s*\(\s*PARTITION\s+BY\s+collector_name", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + [Theory] + [InlineData("mcp")] + [InlineData("viewer")] + public void TheStatementDoesNotRankTheWholeWindow(string which) + { + var (current, oracle) = which == "mcp" + ? (DarlingDataReader.CollectionHealthSql, CollectionHealthOracleSql.Mcp) + : (ViewerDataService.CollectionHealthSql, CollectionHealthOracleSql.Viewer); + + /* The oracle is the shape being left: a rank per newest-row column over the whole window. */ + Assert.Matches(s_fullWindowRank, oracle); + + Assert.DoesNotMatch(s_fullWindowRank, current); + foreach (var rank in new[] { "note_rank", "error_rank", "slowest_rank", "recency_rank" }) + Assert.DoesNotContain(rank, current, StringComparison.Ordinal); + + /* The newest rows come from lookups keyed on an instant the plain aggregate found, one per class. */ + Assert.Contains("LEFT JOIN LATERAL", current, StringComparison.Ordinal); + } + + [Fact] + public async Task TheCurrentPlansRankNothingOverTheWindow_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), + "Set DARLING_TEST_PG to a Postgres connection string to run the live collection-health plan test (it mints its own scratch database)."); + + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(cs!, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteRowsAsync(connection, ct); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var now = Now(); + await SeedEdgeCasesAsync(connection, now, ct); + var windowStart = DarlingMcpTestData.Naive(now.AddDays(-7)); + + /* The oracle's plans are the shape this pin is about: one WindowAgg per rank. */ + Assert.True( + await CountWindowAggsAsync(connection, CollectionHealthOracleSql.Mcp, windowStart, ct) >= 4, + "the oracle should still show one WindowAgg per rank"); + Assert.True( + await CountWindowAggsAsync(connection, CollectionHealthOracleSql.Viewer, windowStart, ct) >= 3, + "the viewer oracle should still show one WindowAgg per rank"); + + /* The viewer's statement has no window left at all; the MCP statement keeps ONE, the trailing + zero-row count, over the rows from the newest streak break on (a lookup, never the window). */ + Assert.Equal(0, await CountWindowAggsAsync(connection, ViewerDataService.CollectionHealthSql, windowStart, ct)); + Assert.Equal(1, await CountWindowAggsAsync(connection, DarlingDataReader.CollectionHealthSql, windowStart, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, DeleteRowsAsync); + } + } + + [Fact] + public async Task BothStatements_ReturnTheOraclesRows_OverEveryEdgeCase_AgainstDevPostgres() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), + "Set DARLING_TEST_PG to a Postgres connection string to run the live collection-health parity test (it mints its own scratch database)."); + + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(cs!, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteRowsAsync(connection, ct); + + var bodySucceeded = false; + try + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + var now = Now(); + await SeedEdgeCasesAsync(connection, now, ct); + var windowStart = DarlingMcpTestData.Naive(now.AddDays(-7)); + + /* ── the real store ── */ + var mcpOracle = await ReadAsync(connection, CollectionHealthOracleSql.Mcp, windowStart, ct); + var mcpCurrent = await ReadAsync(connection, DarlingDataReader.CollectionHealthSql, windowStart, ct); + Assert.Equal(31, mcpOracle.Columns.Count); + AssertSameRows("mcp", mcpOracle, mcpCurrent); + AssertOracleSaw(mcpOracle, now); + + var viewerOracle = await ReadAsync(connection, CollectionHealthOracleSql.Viewer, windowStart, ct); + var viewerCurrent = await ReadAsync(connection, ViewerDataService.CollectionHealthSql, windowStart, ct); + Assert.Equal(19, viewerOracle.Columns.Count); + AssertSameRows("viewer", viewerOracle, viewerCurrent); + + /* ── a session-local copy of the view, for the rows the table's NOT NULL status cannot hold ── + Both statements name v_collection_log unqualified, and pg_temp is searched first, so a temp + table of that name (made by CTAS, which keeps no NOT NULL) stands in for the view on this + connection alone and is gone with it. */ + await DarlingMcpTestData.ExecAsync(connection, ct, + "CREATE TEMP TABLE v_collection_log AS SELECT * FROM collect.v_collection_log WHERE server_id = $1", ServerId); + var nullStatusCasesRan = false; + try + { + await SeedNullStatusCasesAsync(connection, now, ct); + + var mcpOracleNull = await ReadAsync(connection, CollectionHealthOracleSql.Mcp, windowStart, ct); + var mcpCurrentNull = await ReadAsync(connection, DarlingDataReader.CollectionHealthSql, windowStart, ct); + AssertSameRows("mcp+null status", mcpOracleNull, mcpCurrentNull); + var nullTie = mcpOracleNull.Row("null_status_tie"); + Assert.Null(nullTie["current_status"]); + Assert.Null(nullTie["latest_run_note"]); + Assert.Equal(3L, nullTie["trailing_zero_row_success_runs"]); + + var viewerOracleNull = await ReadAsync(connection, CollectionHealthOracleSql.Viewer, windowStart, ct); + var viewerCurrentNull = await ReadAsync(connection, ViewerDataService.CollectionHealthSql, windowStart, ct); + AssertSameRows("viewer+null status", viewerOracleNull, viewerCurrentNull); + Assert.Null(viewerOracleNull.Row("null_status_tie")["latest_run_note"]); + nullStatusCasesRan = true; + } + finally + { + /* #1902: RunOwnedAsync, not RunAsync - the temp table is session-local, so the drop has to run on + this very connection; a fresh one would leave the shadow in place and report success. */ + await LiveStoreCleanup.RunOwnedAsync(nullStatusCasesRan, async () => + await DarlingMcpTestData.ExecAsync(connection, ct, "DROP TABLE IF EXISTS pg_temp.v_collection_log")); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, DeleteRowsAsync); + } + } + + // ── what the oracle must show for the seeded edges: the seeds prove the cases they name ─────────────────── + + private static void AssertOracleSaw(Result mcp, DateTime now) + { + /* Two rows at the newest instant, SUCCESS and ERROR: status DESC puts SUCCESS first, so exactly the one + zero-row success sits ahead of the newest break. */ + var tieStatus = mcp.Row("tie_status"); + Assert.Equal("SUCCESS", tieStatus["current_status"]); + Assert.Equal(1L, tieStatus["trailing_zero_row_success_runs"]); + Assert.Equal("tie error", tieStatus["last_error"]); + + /* Two failures at one instant: the greater text wins, and the older instant's lesser text does not. */ + var tieText = mcp.Row("tie_error_text"); + Assert.Equal("err_zzz", tieText["last_error"]); + Assert.Equal("note_z", tieText["last_note"]); + + /* A failure with text older than one without: the text is the older run's, the time is the newer's. */ + var oldText = mcp.Row("old_text_new_textless"); + Assert.Equal("old text", oldText["last_error"]); + Assert.Equal(DarlingMcpTestData.Naive(now.AddMinutes(-100)), oldText["last_error_time"]); + + /* A SUCCESS note and a failure with text at one instant: neither class picks the other's row. */ + var sameInstant = mcp.Row("note_vs_error_same_instant"); + Assert.Equal("a note", sameInstant["last_note"]); + Assert.Equal("an error", sameInstant["last_error"]); + + /* No failure, or failures with no text: no last_error. */ + Assert.Null(mcp.Row("no_failures")["last_error"]); + Assert.Null(mcp.Row("failures_no_text")["last_error"]); + Assert.Null(mcp.Row("failures_no_text")["last_note"]); + + /* Streaks: broken by an error, never broken, never a success, a NULL predicate that is no break. */ + Assert.Equal(4L, mcp.Row("streak_with_break")["trailing_zero_row_success_runs"]); + Assert.Equal(6L, mcp.Row("streak_no_break")["trailing_zero_row_success_runs"]); + Assert.Equal(0L, mcp.Row("skipped_only")["trailing_zero_row_success_runs"]); + Assert.Equal(2L, mcp.Row("null_predicate")["trailing_zero_row_success_runs"]); + Assert.Equal(2L, mcp.Row("notes_and_abandoned")["trailing_zero_row_success_runs"]); + Assert.Equal(1L, mcp.Row("one_row")["total_runs"]); + + /* Fan-out: the newest of the rows tied on the slowest item, and the NULL one never wins. */ + var fanout = mcp.Row("fanout_ties"); + Assert.Equal("db_new_tied", fanout["slowest_item"]); + Assert.Equal(6, fanout["fanout_items"]); + Assert.Equal(5000, fanout["slowest_item_ms"]); + Assert.Equal(38000, fanout["slowest_run_duration_ms"]); + + /* A dearest run with no duration reports no duration. */ + Assert.Null(mcp.Row("null_duration")["slowest_run_duration_ms"]); + Assert.Equal(900, mcp.Row("null_duration")["slowest_item_ms"]); + + /* No row ever carried a slowest item: rank 1 falls through to the NEWEST row, whose own columns show. */ + var none = mcp.Row("no_fanout_newest"); + Assert.Null(none["slowest_item_ms"]); + Assert.Equal(777, none["slowest_run_duration_ms"]); + Assert.Equal(2, none["fanout_items"]); + Assert.Equal("x", none["slowest_item"]); + + /* Rows before the window are not in it: the only text on this collector is older than the window. */ + var before = mcp.Row("before_window"); + Assert.Null(before["last_error"]); + Assert.Null(before["last_note"]); + Assert.Equal(2L, before["total_runs"]); + } + + // ── the seeds ───────────────────────────────────────────────────────────────────────────────────────────── + + private static DateTime Now() + { + var now = DateTime.SpecifyKind(LiveClock.Now(), DateTimeKind.Utc); + return new DateTime(now.Ticks - (now.Ticks % TimeSpan.TicksPerMillisecond), DateTimeKind.Utc); + } + + private static async Task SeedEdgeCasesAsync(NpgsqlConnection c, DateTime now, CancellationToken ct) + { + DateTime Ago(double minutes) => now.AddMinutes(-minutes); + Task Seed(string collector, double minutesAgo, string status, string? message = null, + int? durationMs = 100, int? rows = 10, int? fanoutItems = null, string? slowestItem = null, + int? slowestItemMs = null) => + SeedAsync(c, ct, collector, Ago(minutesAgo), status, message, durationMs, rows, fanoutItems, slowestItem, slowestItemMs); + + /* Two rows at the newest instant with different statuses: current_status takes the greater status, and + the streak count puts the zero-row SUCCESS ahead of the ERROR that breaks it. */ + await Seed("tie_status", 300, "SUCCESS", rows: 10); + await Seed("tie_status", 200, "SUCCESS", rows: 0); + await Seed("tie_status", 100, "SUCCESS", rows: 0); + await Seed("tie_status", 50, "SUCCESS", rows: 0); + await Seed("tie_status", 50, "ERROR", "tie error", rows: 0); + + /* Two failures at one instant with different texts, and two notes at one (older) instant. */ + await Seed("tie_error_text", 400, "SUCCESS", "note_a", rows: 0); + await Seed("tie_error_text", 400, "SUCCESS", "note_z", rows: 0); + await Seed("tie_error_text", 300, "ERROR", "err_aaa", rows: 0); + await Seed("tie_error_text", 300, "PERMISSIONS", "err_zzz", rows: 0); + await Seed("tie_error_text", 100, "SUCCESS", rows: 5); + + /* NULL durations, one of them on the dearest fan-out run. */ + await Seed("null_duration", 200, "SUCCESS", durationMs: null); + await Seed("null_duration", 150, "SUCCESS", durationMs: null, fanoutItems: 3, slowestItem: "db_a", slowestItemMs: 900); + await Seed("null_duration", 100, "SUCCESS", durationMs: 40); + + /* No failures at all. */ + await Seed("no_failures", 300, "SUCCESS"); + await Seed("no_failures", 200, "SUCCESS"); + await Seed("no_failures", 100, "SUCCESS"); + + /* Failures that carry no text. */ + await Seed("failures_no_text", 200, "ERROR", rows: 0); + await Seed("failures_no_text", 100, "PERMISSIONS", rows: 0); + + /* A failure with text older than a failure without: the text is the older run's. */ + await Seed("old_text_new_textless", 300, "ERROR", "old text", rows: 0); + await Seed("old_text_new_textless", 100, "ERROR", rows: 0); + await Seed("old_text_new_textless", 50, "SUCCESS", rows: 3); + + /* A SUCCESS note and a failure with text at ONE instant, neither the newest run. */ + await Seed("note_vs_error_same_instant", 200, "SUCCESS", "a note", rows: 0); + await Seed("note_vs_error_same_instant", 200, "ERROR", "an error", rows: 0); + await Seed("note_vs_error_same_instant", 100, "SUCCESS", rows: 4); + + /* Notes, a run the wall-clock budget abandoned before and after the ABANDONED status existed. */ + var budget = EnumeratedCollectorDriver.WholeCycleBudgetNote(120); + await Seed("notes_and_abandoned", 400, "SUCCESS", budget, rows: 0); + await Seed("notes_and_abandoned", 300, EnumeratedCollectorDriver.AbandonedStatus, budget, rows: 0); + await Seed("notes_and_abandoned", 200, "SUCCESS", EnumeratedCollectorDriver.EmptyEnumerationMessage, rows: 0); + await Seed("notes_and_abandoned", 100, "SUCCESS", rows: 0); + + /* A streak with a break, one without, and a collector that only ever skipped. */ + await Seed("streak_with_break", 700, "SUCCESS", rows: 10); + await Seed("streak_with_break", 600, "SUCCESS", rows: 0); + await Seed("streak_with_break", 500, "SUCCESS", rows: 0); + await Seed("streak_with_break", 400, "SUCCESS", rows: 0); + await Seed("streak_with_break", 300, "ERROR", "boom", rows: 0); + foreach (var minutes in new double[] { 200, 150, 100, 50 }) + await Seed("streak_with_break", minutes, "SUCCESS", rows: 0); + foreach (var minutes in new double[] { 600, 500, 400, 300, 200, 100 }) + await Seed("streak_no_break", minutes, "SUCCESS", rows: 0); + foreach (var minutes in new double[] { 300, 200, 100 }) + await Seed("skipped_only", minutes, "SKIPPED", rows: 0); + + /* A row whose streak predicate is NULL (a NULL row count beside the budget note) is no break. */ + await Seed("null_predicate", 300, "SUCCESS", rows: 5); + await Seed("null_predicate", 200, "SUCCESS", budget, rows: null); + await Seed("null_predicate", 100, "SUCCESS", rows: 0); + + /* One run only. */ + await Seed("one_row", 100, "SUCCESS", rows: 1); + + /* Three rows tied on the slowest item (the newest of them wins), a smaller one, and a NEWER row with no + slowest item at all. */ + await Seed("fanout_ties", 500, "SUCCESS", durationMs: 40000, fanoutItems: 8, slowestItem: "db_old", slowestItemMs: 5000); + await Seed("fanout_ties", 400, "SUCCESS", durationMs: 39000, fanoutItems: 7, slowestItem: "db_mid", slowestItemMs: 5000); + await Seed("fanout_ties", 300, "SUCCESS", durationMs: 38000, fanoutItems: 6, slowestItem: "db_new_tied", slowestItemMs: 5000); + await Seed("fanout_ties", 200, "SUCCESS", durationMs: 20, fanoutItems: 5, slowestItem: "db_small", slowestItemMs: 100); + await Seed("fanout_ties", 100, "SUCCESS", durationMs: 10); + + /* No row has a slowest item: the newest row's own fan-out columns and duration come out. */ + await Seed("no_fanout_newest", 300, "SUCCESS", durationMs: 50); + await Seed("no_fanout_newest", 200, "SUCCESS", durationMs: 60); + await Seed("no_fanout_newest", 100, "SUCCESS", durationMs: 777, fanoutItems: 2, slowestItem: "x"); + + /* Every status the counts name, with text on the failures. */ + await Seed("status_mix", 800, "EXTENSION_MISSING", "install the extension"); + await Seed("status_mix", 700, "SESSION_MISSING", "no session"); + await Seed("status_mix", 600, "YIELDED", "yielded"); + await Seed("status_mix", 500, "CANCELLED", "cancelled"); + await Seed("status_mix", 400, "PERMISSIONS", "denied"); + await Seed("status_mix", 300, "SKIPPED"); + await Seed("status_mix", 200, "ERROR", "failed"); + await Seed("status_mix", 100, "SUCCESS", "a note", rows: 2); + + /* Rows just before the window, a row exactly on its start, and one after. */ + var windowStartMinutes = 7 * 24 * 60.0; + await Seed("before_window", windowStartMinutes + 1, "ERROR", "before window"); + await Seed("before_window", windowStartMinutes + 0.001, "SUCCESS", "old note"); + await Seed("before_window", windowStartMinutes, "SUCCESS", rows: 0); + await Seed("before_window", 100, "SUCCESS", rows: 0); + } + + /// Rows the real table cannot hold (its status is NOT NULL), into the session-local copy of the view. + private static async Task SeedNullStatusCasesAsync(NpgsqlConnection c, DateTime now, CancellationToken ct) + { + Task Seed(double minutesAgo, string? status, string? message, int? rows) => + DarlingMcpTestData.ExecAsync(c, ct, @" +INSERT INTO pg_temp.v_collection_log + (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, error_message, rows_collected) +VALUES ($1, $2, $3, 'null_status_tie', $4, 100, $5, $6, $7)", + CollectionIdGenerator.Next(), ServerId, ServerName, + DarlingMcpTestData.Naive(now.AddMinutes(-minutesAgo)), status, message, rows); + + /* A NULL status beside a SUCCESS at the newest instant: DESC puts the NULL first, so the newest run's + status and note read as NULL; the NULL-status row with a zero count is no break, the one with a + count is. */ + await Seed(300, "ERROR", "e", 0); + await Seed(250, null, null, 10); + await Seed(200, "SUCCESS", null, 0); + await Seed(100, null, "null note", 0); + await Seed(100, "SUCCESS", null, 0); + } + + private static Task SeedAsync( + NpgsqlConnection c, CancellationToken ct, string collector, DateTime atUtc, string status, string? message, + int? durationMs, int? rows, int? fanoutItems, string? slowestItem, int? slowestItemMs) => + DarlingMcpTestData.ExecAsync(c, ct, @" +INSERT INTO collection_log + (log_id, server_id, server_name, collector_name, collection_time, duration_ms, status, error_message, + rows_collected, sql_duration_ms, duckdb_duration_ms, fanout_item_count, slowest_item, slowest_item_ms) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", + CollectionIdGenerator.Next(), ServerId, ServerName, collector, DarlingMcpTestData.Naive(atUtc), + durationMs, status, message, rows, 80, 20, fanoutItems, slowestItem, slowestItemMs); + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM collection_log WHERE server_id = $1", ServerId); + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM servers WHERE server_id = $1", ServerId); + await DarlingMcpTestData.ExecAsync(connection, ct, "DELETE FROM config_monitored_servers WHERE server_id = $1", ServerId); + } + + // ── reading and comparing ───────────────────────────────────────────────────────────────────────────────── + + private sealed record Column(string Name, string PgType, Type ClrType); + + private sealed class Result + { + public List Columns { get; } = new(); + + public List Rows { get; } = new(); + + public Dictionary Row(string collector) + { + var row = Rows.Single(r => (string)r[0]! == collector); + return Columns.Select((col, i) => (col.Name, Value: row[i])).ToDictionary(p => p.Name, p => p.Value); + } + } + + private static NpgsqlCommand Bind(NpgsqlConnection connection, string sql, DateTime windowStart) + { + var command = new NpgsqlCommand(sql, connection); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Integer, Value = ServerId }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlDbType.Timestamp, Value = windowStart }); + return command; + } + + private static async Task ReadAsync(NpgsqlConnection connection, string sql, DateTime windowStart, CancellationToken ct) + { + await using var command = Bind(connection, sql, windowStart); + await using var reader = await command.ExecuteReaderAsync(ct); + var result = new Result(); + for (var i = 0; i < reader.FieldCount; i++) + result.Columns.Add(new Column(reader.GetName(i), reader.GetDataTypeName(i), reader.GetFieldType(i))); + while (await reader.ReadAsync(ct)) + { + var values = new object?[reader.FieldCount]; + for (var i = 0; i < values.Length; i++) + values[i] = reader.IsDBNull(i) ? null : reader.GetValue(i); + result.Rows.Add(values); + } + + return result; + } + + private static void AssertSameRows(string label, Result oracle, Result current) + { + Assert.Equal(oracle.Columns, current.Columns); + Assert.Equal(oracle.Rows.Count, current.Rows.Count); + Assert.True(oracle.Rows.Count >= 10, label + ": the seed should give the oracle many collectors"); + for (var r = 0; r < oracle.Rows.Count; r++) + { + for (var i = 0; i < oracle.Columns.Count; i++) + { + Assert.True( + Equals(oracle.Rows[r][i], current.Rows[r][i]), + $"{label}: collector {oracle.Rows[r][0]}, column {oracle.Columns[i].Name}: " + + $"oracle <{oracle.Rows[r][i] ?? "NULL"}> but current <{current.Rows[r][i] ?? "NULL"}>"); + } + } + } + + private static async Task CountWindowAggsAsync( + NpgsqlConnection connection, string sql, DateTime windowStart, CancellationToken ct) + { + await using var command = Bind(connection, "EXPLAIN (FORMAT JSON) " + sql, windowStart); + var json = (string)(await command.ExecuteScalarAsync(ct))!; + using var document = JsonDocument.Parse(json); + return CountNodes(document.RootElement, "WindowAgg"); + } + + private static int CountNodes(JsonElement element, string nodeType) + { + var count = 0; + switch (element.ValueKind) + { + case JsonValueKind.Object: + if (element.TryGetProperty("Node Type", out var type) && type.GetString() == nodeType) + count++; + foreach (var property in element.EnumerateObject()) + count += CountNodes(property.Value, nodeType); + break; + case JsonValueKind.Array: + foreach (var item in element.EnumerateArray()) + count += CountNodes(item, nodeType); + break; + } + + return count; + } +} diff --git a/Darling/Darling.Tests/CollectionHealthOracleSql.cs b/Darling/Darling.Tests/CollectionHealthOracleSql.cs new file mode 100644 index 0000000000..cb5de49924 --- /dev/null +++ b/Darling/Darling.Tests/CollectionHealthOracleSql.cs @@ -0,0 +1,482 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using PerformanceMonitor.Collectors; + +namespace Darling.Tests; + +/// +/// The per-server collection-health statements as they read before #4955 replaced their four (MCP) and three +/// (viewer) full-window ROW_NUMBER() ranks with keyed lookups, kept VERBATIM as the oracle the rewrite is +/// held to: runs each beside its current statement over the +/// same seeded rows and requires every column of every row to agree. Do not edit these to follow the product +/// statements; they are the definition of "the same values". +/// +internal static class CollectionHealthOracleSql +{ + /// The MCP service's statement (DarlingDataReader.CollectionHealthSql) before #4955: 31 columns. + public const string Mcp = $""" + SELECT + collector_name, + COUNT(*) AS total_runs, + -- #2926: SUCCESS excludes an abandonment that predates #2803, so the Success column beside + -- Abandoned cannot count the same run twice. Post-#2803 rows need no exclusion - ABANDONED + -- is not SUCCESS - and an ordinary empty run stays counted, which is what the COALESCE in + -- the shared predicate is for: NULL under this NOT would have dropped it. + SUM(CASE WHEN status = 'SUCCESS' + AND NOT {EnumeratedCollectorDriver.AbandonedByNotePredicateSql} + THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS error_count, + AVG(duration_ms) AS avg_duration_ms, + -- #2460: the mean above describes a collector whose runs all cost about the same, and + -- says nothing true about one whose runs come in two sizes. query_store on a dense shard + -- reported a 13,834 ms average over 1,155 runs where 958 of them yielded nothing and cost + -- ~36 ms, which puts the other 197 at ~80,900 ms EACH — each one on its own larger than + -- the whole 60,000 ms sweep budget. duration_ms has been written per run since V2; + -- nothing had ever read it as anything but a mean. + -- + -- p95 rather than the max for the number a decision is made from: a max is one run, so a + -- single pathological cycle would make a collector look permanently terrible for the rest + -- of the window. p95 also scales itself to the sample — over 3,500 runs it discards the + -- one bad cycle, and over the six runs a daily collector gets in a week it lands on the + -- max, which is right, because with six samples there is no outlier anyone can afford to + -- throw away. DISC rather than CONT so the answer is a duration some run actually took + -- instead of an interpolation between the two modes, which would be a number describing + -- no run at all — the exact defect this column exists to end. Both engines ignore NULL + -- duration_ms here, as AVG already does. Byte-identical to Lite's DuckDB read. + MAX(duration_ms) AS max_duration_ms, + PERCENTILE_DISC(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95_duration_ms, + MAX(CASE WHEN status IN ('SUCCESS', 'SKIPPED') THEN collection_time END) AS last_success_time, + MAX(collection_time) AS last_run_time, + -- #1855: the message from the NEWEST failing run, not MAX()'s lexicographically greatest + -- one. The status re-check is load-bearing rather than belt-and-braces: when no failing run + -- in the window carried text, error_rank = 1 falls through to the newest row of ANY class, + -- and without it a SUCCESS row's note could surface here as a fake last error. + -- #3240: EXTENSION_MISSING is in the exemplar set because its stored sentence IS the remedy + -- (it names the extension and the database to create it in). + MAX(CASE WHEN error_rank = 1 AND status IN ('ERROR', 'PERMISSIONS', 'EXTENSION_MISSING') THEN error_message END) AS last_error, + -- The newest failure OUTRIGHT, text or not — "when did this last fail" means the run, not + -- the message. + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS', 'EXTENSION_MISSING') THEN collection_time END) AS last_error_time, + SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END) AS permission_denied_count, + SUM(CASE WHEN status = 'YIELDED' THEN 1 ELSE 0 END) AS yield_count, + -- #1837: the note a SUCCEEDING run can leave behind (an enumeration that yielded 0 items, + -- items whose enumeration probe failed) and how many runs carried one. Gated on SUCCESS + -- specifically — the runners attach a note only to the SUCCESS write. Informational: it + -- feeds no band, and a legitimately empty target stays HEALTHY. + MAX(CASE WHEN note_rank = 1 AND status = 'SUCCESS' THEN error_message END) AS last_note, + COUNT(CASE WHEN status = 'SUCCESS' THEN error_message END) AS note_count, + -- #1852: the one thing that makes a persistently-empty enumeration interesting — does this + -- target actually HAVE user databases? Zero items on a server with none is legitimate and + -- stays quiet; zero items on a server that HAS them is a login that cannot enter any, or a + -- filter that swallowed everything. database_size_stats rather than database_config for + -- three reasons: it runs on the scheduled loop (60 min) where database_config is on-load + -- and can age past this window on a long-running service; it is indexed on + -- (server_id, collection_time) where database_config has no index at all; and it reads + -- sys.master_files, so it still sees databases the monitoring login cannot ENTER — exactly + -- the case being diagnosed. database_id > 4 excludes the system databases, tempdb + -- included: the size collector takes every ONLINE database, so a bare row check + -- would be true on every server alive. A NULL database_id is an Azure sibling row + -- (#2643/#3262): sys.resource_stats carries no id, and it bills only USER databases, + -- so those rows are inventory too — without the IS NULL arm a master-connected Azure + -- target with fifty user databases would read as having none. + -- + -- The inventory window is the health read's OWN ($2) — no second parameter, and an + -- inventory that aged out says nothing rather than something stale. Uncorrelated, so both + -- engines evaluate it once per query (a Postgres InitPlan) instead of per row, and it + -- needs no GROUP BY entry and no join. Feeds display text only, through the shared + -- formatter; the banding never sees it. + CASE + WHEN EXISTS + ( + SELECT 1 + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND (database_id > 4 OR database_id IS NULL) + ) + THEN 1 + ELSE 0 + END AS has_user_databases, + -- #2472: the per-database fan-out, described for ONE run — the dearest one in the window. + -- Five collectors run once per database and the run writes a single blended duration_ms, so + -- "eight databases at 10.1s" and "one at 62s beside seven at 2.7s" are the same 80,900 ms and + -- want opposite fixes. These four are that one run's parts, and their ratio + -- (slowest_item_ms * fanout_items / slowest_run_duration_ms) is 1.0 for an even fan-out and + -- 6.1 for the dominated example. All four come from the SAME row via slowest_rank rather than + -- four independent aggregates, because a slowest item taken from one run and a width taken + -- from another compose into a ratio describing no run that ever happened — the same defect + -- PERCENTILE_CONT was rejected for above. NULL throughout for a collector that never fans + -- out, which is most of them. + MAX(CASE WHEN slowest_rank = 1 THEN fanout_item_count END) AS fanout_items, + MAX(CASE WHEN slowest_rank = 1 THEN slowest_item END) AS slowest_item, + MAX(CASE WHEN slowest_rank = 1 THEN slowest_item_ms END) AS slowest_item_ms, + MAX(CASE WHEN slowest_rank = 1 THEN duration_ms END) AS slowest_run_duration_ms, + -- #2804: runs the #2673 wall-clock budget abandoned. Appended LAST rather than placed beside + -- yield_count, which is where it belongs by meaning: both MCP surfaces read this result set + -- POSITIONALLY and Lite's DuckDB read mirrors these ordinals, so inserting mid-list would + -- silently re-map every column after it in whichever surface was not edited in the same + -- breath. An ABANDONED run was previously counted by total_runs and by nothing else, so it + -- grew the failure-rate denominator while contributing nothing to the numerator. + -- + -- #2926: keyed on the ROW, not on the status alone. collection_log is append-only, so a + -- window can still hold cycles written before #2803 gave abandonment its own status: + -- status = 'SUCCESS' beside rows_collected = 0 and the budget note. Counted by status + -- alone this read 0 for them, and the collector banded HEALTHY while losing cycles - a + -- filter correct against current writes and silently wrong against older ones, failing in + -- the reassuring direction. The pattern is one LIKE because the budget is INTERPOLATED and + -- the shipped values differ (120 s for procedure_stats/query_stats/plan_correction, 600 s + -- for query_store), so equality against one rendered sentence matches one collector. + SUM(CASE WHEN {EnumeratedCollectorDriver.AbandonedRunPredicateSql} + THEN 1 ELSE 0 END) AS abandoned_count, + -- #3010: the newest DENIAL on its own, which is what dates the last_error slot above. + -- last_error_time cannot stand in for it: that column is a MAX over ERROR and PERMISSIONS + -- together, so on a collector carrying both it hands a reader an error's instant and lets + -- them call it a denial. Compared against last_success_time this separates a collector + -- still being refused from one whose refusals all predate a later success -- the exact + -- distinction pg_deadlocks needed and no surface could make. + -- + -- Appended LAST, like abandoned_count before it: both MCP surfaces read this result set + -- POSITIONALLY and Lite's DuckDB read mirrors these ordinals, so a mid-list insert would + -- silently re-map every column after it in whichever surface was not edited in the same + -- breath. + MAX(CASE WHEN status = 'PERMISSIONS' THEN collection_time END) AS last_denied_time, + -- #3017: what the spend BOUGHT. Every other statistic on a health row describes cost -- + -- total_runs, the three durations, and the sweep-pressure roll-up built from them -- and the + -- rows figure lived on get_collector_cost, a different tool over a different (hourly, fleet- + -- wide) series. Correlating spend against output was a join a reader had to know to make. + -- Measured: pg_deadlocks was the dearest collector on a managed store, 49,258,335 ms over + -- 79,333 runs in seven days, and stored zero rows. + -- + -- Read from THIS query's own window so cost and output cannot describe different runs. That + -- is the same reason #3010's two instants come out of one aggregate: a rows figure taken + -- from one window beside a duration taken from another composes into a ratio describing no + -- collector that ever ran. + -- + -- COALESCE so a zero is unambiguous at the STORE. Without it a collector whose every + -- rows_collected is NULL returns NULL here, which a reader would have to guess between "this + -- read did not measure output" and "it stored nothing" -- and the whole point of the column + -- is that the second of those becomes a fact rather than an absence. + COALESCE(SUM(rows_collected), 0) AS rows_stored, + -- The DENOMINATOR's partner, and the honest half of a cost/output pair: 12 rows over 3 of + -- 79,333 runs is a different collector from 12 rows over all of them. get_pg_blocking already + -- reports captures_with_blocking beside captures_total for exactly this reason, off this same + -- rows_collected > 0 test. total_runs above is the denominator; this is the numerator. + -- + -- APPENDED, like every column since #2472: both MCP surfaces read this result set + -- POSITIONALLY and Lite's DuckDB read mirrors these ordinals, so a mid-list insert would + -- silently re-map every column after it in whichever surface was not edited in the same + -- breath. + SUM(CASE WHEN rows_collected > 0 THEN 1 ELSE 0 END) AS runs_with_rows, + -- #3240: runs skipped because a PostgreSQL extension the collector DECLARES is not installed + -- — the EXTENSION_MISSING status the fault mapper split out of PERMISSIONS, counted apart so + -- the banding stops calling an uninstalled optional extension NO_PERMISSIONS. APPENDED, like + -- every column since #2472, because this result set is read positionally. + SUM(CASE WHEN status = 'EXTENSION_MISSING' THEN 1 ELSE 0 END) AS extension_missing_count, + -- #3754: runs whose XE session was missing or could not be created - the SESSION_MISSING status + -- the tolerant XE readers write for a permission-denied session read and, since #3754, the + -- long-query reconcile writes for a session refused in every database. Until now this status + -- reached this surface as total_runs and NOTHING else: not an error, not a success, not a + -- denial, so a collector whose every run was SESSION_MISSING banded FAILING on the never- + -- succeeded clock while the row said errors 0 and the output finding said it had read and + -- found nothing. Counted apart from error_count on purpose - it is not fed to the band, whose + -- capture-down story belongs to the self-alert - and fed with error_count to the output + -- finding as the runs that could not read. APPENDED, like every column since #2472, because + -- this result set is read positionally and Lite's DuckDB read mirrors the ordinals. + SUM(CASE WHEN status = 'SESSION_MISSING' THEN 1 ELSE 0 END) AS session_missing_count, + -- #3819: the three columns that tell a collector which STOPPED producing apart from one that + -- never produced here. The same named skip carries opposite meanings on those two rows, and + -- until now the surface read both as the benign resting state -- which is how an install that + -- took pg_statement_stats from 85% productive to EXTENSION_MISSING on 23 of 50 clusters was + -- accepted by the countersign for 24 hours. APPENDED, like every column since #2472, because + -- this result set is read positionally and Lite's DuckDB read mirrors the ordinals. + -- + -- current_status is what the collector is reporting NOW, for the finding's prose. Taken at + -- recency_rank = 1 rather than as a MAX over the skip rows: MAX is lexicographic, so on a + -- streak whose status changed it would name whichever word sorts highest instead of the one + -- being reported. + MAX(CASE WHEN recency_rank = 1 THEN status END) AS current_status, + -- The instant the current skip streak began AFTER: the newest run that was NOT a named skip. + -- The vocabulary is interpolated from CollectorRuntimePrecondition, which is where each of + -- those statuses is declared, so this cannot ask about three of four after a fourth is split + -- out. A NULL status counts as non-skip: it is not one of the declared skip words, and reading + -- it as one would let an unwritten status manufacture a streak. + MAX(CASE WHEN status IS NULL + OR status NOT IN ({CollectorRuntimePrecondition.NamedSkipStatusSqlList}) + THEN collection_time END) AS last_non_skip_time, + -- The newest run that stored anything. Off the same rows_collected > 0 test as runs_with_rows + -- above, so productive means one thing on this row. Compared against last_non_skip_time it + -- says the productivity sits BEFORE the streak rather than inside it, which is the ORDER that + -- makes this a regression rather than two unrelated facts. + MAX(CASE WHEN rows_collected > 0 THEN collection_time END) AS last_productive_time, + -- #3885: how many runs, counting back from the NEWEST, were SUCCESS with zero rows and nothing + -- else. The column that makes the #3819 regression arm able to see the class it could not: + -- job_history recorded SUCCESS / 0 rows run after run on 41 of 43 servers of the largest + -- production store for up to a fortnight -- 1,900+ consecutive such runs on one of them -- and + -- banded HEALTHY throughout, because #3819 keys on a skip STATUS and this collector's status was + -- the most reassuring word the vocabulary has. + -- + -- Exact, and FREE: recency_rank already exists in the subquery below (#3819 added it for + -- current_status), so this buys the streak's true width with no new window function and no new + -- sort. MIN of the rank of the newest run that BREAKS the streak, minus one, is the count of + -- runs ahead of it; NULL (no run breaks it -- every run in the window is a zero-row success) + -- falls back to COUNT(*), which is that same count. A streak broken by an error, a denial, a + -- skip or a productive run therefore reads 0 rather than reaching past it, because what this + -- measures is the collector's CURRENT state and any of those is a different current state. + -- + -- The abandonment exclusion is the one success_count above uses, for the same #2926 reason: a + -- pre-#2803 abandoned cycle is stored as SUCCESS with zero rows and the budget note, which is + -- data LOSS rather than a source that went quiet, and counting it here would attribute an + -- abandonment to a regression. APPENDED, like every column since #2472, because this result set + -- is read positionally. + COALESCE( + MIN(CASE WHEN NOT (status = 'SUCCESS' + AND COALESCE(rows_collected, 0) = 0 + AND NOT {EnumeratedCollectorDriver.AbandonedByNotePredicateSql}) + THEN recency_rank END) - 1, + COUNT(*)) AS trailing_zero_row_success_runs, + -- #4748: the note the collector's NEWEST run left, which is not last_note above. last_note is the + -- newest run that CARRIED a note (note_rank), so a clean run after a partial-failure cycle still + -- shows the older cycle's note there; the band must not read that, because the loss it names + -- is not the collector's current state. recency_rank = 1 is the newest run of any status, and the + -- SUCCESS gate matches last_note's (only the SUCCESS write carries a note). APPENDED like every + -- column since #2472, because this result set is read positionally and Lite's DuckDB read + -- mirrors the ordinals. + MAX(CASE WHEN recency_rank = 1 AND status = 'SUCCESS' THEN error_message END) AS latest_run_note + FROM + ( + -- #1855: rank each class of message newest-first so the two exemplar columns above can take + -- the LATEST one instead of the lexicographically greatest. Ordering on whether the class's + -- CASE came back empty puts every row that carries such a message ahead of every row that + -- does not, so rank 1 is the newest one that has text — and a later clean run no longer + -- blanks a note the window still holds. Text does not sort like the number #1837's probe + -- note carries: 12 item(s) sorts below 9 item(s). One byte-identical shape with Lite's + -- DuckDB read and the Viewer's, down to the error_message DESC tie-break. + SELECT + collector_name, + collection_time, + duration_ms, + status, + error_message, + -- #2926: the abandonment predicate above reads it. Projected here for the same reason + -- #2472's three columns are: this subquery ENUMERATES its columns, so an aggregate + -- outside naming one it does not carry fails at the STORE and nowhere earlier. + rows_collected, + -- #2472: projected here because this subquery enumerates its columns rather than + -- SELECT *-ing them, so an aggregate outside that names a column the inner query does + -- not carry fails at the STORE and nowhere earlier — no compiler, no text assertion and + -- no local build can see it. + fanout_item_count, + slowest_item, + slowest_item_ms, + ROW_NUMBER() OVER + ( + PARTITION BY collector_name + ORDER BY (CASE WHEN status = 'SUCCESS' THEN error_message END) IS NULL, + collection_time DESC, + error_message DESC + ) AS note_rank, + ROW_NUMBER() OVER + ( + PARTITION BY collector_name + ORDER BY (CASE WHEN status IN ('ERROR', 'PERMISSIONS', 'EXTENSION_MISSING') THEN error_message END) IS NULL, + collection_time DESC, + error_message DESC + ) AS error_rank, + -- #2472: the window's dearest single ITEM, and with it the run that carried it. Ranked on + -- slowest_item_ms rather than duration_ms because the question is which database is + -- expensive, not which cycle was: a run whose total is the largest only because every + -- database was busy is exactly the shape a per-database override should NOT be aimed at. + ROW_NUMBER() OVER + ( + PARTITION BY collector_name + ORDER BY slowest_item_ms IS NULL, + slowest_item_ms DESC, + collection_time DESC + ) AS slowest_rank, + -- #3819: newest run first, so current_status above can take the status the collector is + -- reporting NOW. status DESC only breaks an exact-timestamp tie, and breaks it identically + -- on DuckDB and Postgres -- the same reason the ranks above tie-break on error_message. + ROW_NUMBER() OVER + ( + PARTITION BY collector_name + ORDER BY collection_time DESC, + status DESC + ) AS recency_rank + FROM v_collection_log + WHERE server_id = $1 + AND collection_time >= $2 + ) runs + GROUP BY collector_name + ORDER BY collector_name + """; + + /// The WPF viewer's statement (ViewerDataService.CollectionHealthSql) before #4955: 19 columns. + public const string Viewer = $""" + SELECT + collector_name, + COUNT(*) AS total_runs, + -- #2926: SUCCESS excludes an abandonment that predates #2803, so the Success column beside + -- Abandoned cannot count the same run twice. Post-#2803 rows need no exclusion - ABANDONED + -- is not SUCCESS - and an ordinary empty run stays counted, which is what the COALESCE in + -- the shared predicate is for: NULL under this NOT would have dropped it. + SUM(CASE WHEN status = 'SUCCESS' + AND NOT {EnumeratedCollectorDriver.AbandonedByNotePredicateSql} + THEN 1 ELSE 0 END) AS success_count, + SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS error_count, + AVG(duration_ms) AS avg_duration_ms, + -- SKIPPED counts as a healthy run (dedup / version-gated collectors no-op without being stale) + MAX(CASE WHEN status IN ('SUCCESS', 'SKIPPED') THEN collection_time END) AS last_success_time, + MAX(collection_time) AS last_run_time, + -- #1855: the message from the NEWEST failing run, not MAX()'s lexicographically greatest + -- one. The status re-check is load-bearing rather than belt-and-braces: when no failing run + -- in the window carried text, error_rank = 1 falls through to the newest row of ANY class, + -- and without it a SUCCESS row's note could surface here as a fake last error. + -- #3240: EXTENSION_MISSING is in the exemplar set because its stored sentence IS the remedy + -- (it names the extension and the database) — without it an EXTENSION_MISSING band would sit + -- beside a blank Last Error and the operator would have to open the run log to learn why. + MAX(CASE WHEN error_rank = 1 AND status IN ('ERROR', 'PERMISSIONS', 'EXTENSION_MISSING') THEN error_message END) AS last_error, + -- The newest failure OUTRIGHT, text or not — "when did this last fail" means the run, not + -- the message. It can only name a different row than last_error if a failure was written + -- with no text. + MAX(CASE WHEN status IN ('ERROR', 'PERMISSIONS', 'EXTENSION_MISSING') THEN collection_time END) AS last_error_time, + SUM(CASE WHEN status = 'PERMISSIONS' THEN 1 ELSE 0 END) AS permission_denied_count, + -- YIELDED = the 1s LOCK_TIMEOUT guard fired (#1805): deliberate, benign for collection, + -- counted apart from errors because clustering here is a signal about the TARGET's lock + -- contention rather than a monitoring fault. + SUM(CASE WHEN status = 'YIELDED' THEN 1 ELSE 0 END) AS yield_count, + -- #1837: the note a SUCCEEDING run can leave behind (an enumeration that yielded 0 items, + -- items whose enumeration probe failed). Gated on SUCCESS specifically, rather than on + -- every non-failure status: the runners attach a note only to the SUCCESS write, and the looser + -- complement would drag SESSION_MISSING and CANCELLED messages into a column whose whole + -- claim is that it is NOT an error. Display text only: no band, no count, no threshold + -- reads it, and a legitimately empty target stays HEALTHY exactly as before. + MAX(CASE WHEN note_rank = 1 AND status = 'SUCCESS' THEN error_message END) AS last_note, + -- How many of the window's runs carried one. note_count = total_runs is the + -- persistently-empty signal: EVERY run this week came back with nothing. + COUNT(CASE WHEN status = 'SUCCESS' THEN error_message END) AS note_count, + -- #1852: the one thing that makes a persistently-empty enumeration interesting — does this + -- target actually HAVE user databases? Zero items on a server with none is legitimate and + -- stays quiet; zero items on a server that HAS them is a login that cannot enter any, or a + -- filter that swallowed everything. database_size_stats rather than database_config for + -- three reasons: it runs on the scheduled loop (60 min) where database_config is on-load + -- and can age past this window on a long-running service; it is indexed on + -- (server_id, collection_time) where database_config has no index at all; and it reads + -- sys.master_files, so it still sees databases the monitoring login cannot ENTER — exactly + -- the case being diagnosed. database_id > 4 excludes the system databases, tempdb + -- included: the size collector takes every ONLINE database, so a bare row check + -- would be true on every server alive. A NULL database_id is an Azure sibling row + -- (#2643/#3262): sys.resource_stats carries no id, and it bills only USER databases, + -- so those rows are inventory too — without the IS NULL arm a master-connected Azure + -- target with fifty user databases would read as having none. + -- + -- The inventory window is the health read's OWN ($2) — no second parameter, and an + -- inventory that aged out says nothing rather than something stale. Uncorrelated, so both + -- engines evaluate it once per query (a Postgres InitPlan) instead of per row, and it + -- needs no GROUP BY entry and no join. Feeds display text only, through the shared + -- formatter; the banding never sees it. + CASE + WHEN EXISTS + ( + SELECT 1 + FROM v_database_size_stats + WHERE server_id = $1 + AND collection_time >= $2 + AND (database_id > 4 OR database_id IS NULL) + ) + THEN 1 + ELSE 0 + END AS has_user_databases, + -- #2804: runs the #2673 wall-clock budget abandoned. Appended last — this result set is + -- read positionally by one shared mapper serving BOTH the per-server and fleet reads. + -- + -- #2926: keyed on the ROW, not on the status alone. collection_log is append-only, so a + -- window can still hold cycles written before #2803 gave abandonment its own status: + -- status = 'SUCCESS' beside rows_collected = 0 and the budget note. Counted by status + -- alone this read 0 for them, and the collector banded HEALTHY while losing cycles - a + -- filter correct against current writes and silently wrong against older ones, failing in + -- the reassuring direction. The pattern is one LIKE because the budget is INTERPOLATED and + -- the shipped values differ (120 s for procedure_stats/query_stats/plan_correction, 600 s + -- for query_store), so equality against one rendered sentence matches one collector. + SUM(CASE WHEN {EnumeratedCollectorDriver.AbandonedRunPredicateSql} + THEN 1 ELSE 0 END) AS abandoned_count, + -- #3240: runs skipped because a PostgreSQL extension the collector DECLARES is not installed + -- — the EXTENSION_MISSING status the fault mapper split out of PERMISSIONS, counted apart so + -- the banding stops calling an uninstalled optional extension NO_PERMISSIONS. APPENDED, never + -- inserted: this result set is read positionally by one shared mapper. + SUM(CASE WHEN status = 'EXTENSION_MISSING' THEN 1 ELSE 0 END) AS extension_missing_count, + -- #3819: the two instants that, with last_run_time above, say whether this collector + -- STOPPED producing rather than never having produced here. The same named skip carries + -- opposite meanings on those two rows, and the band read both as the benign resting state. + -- This grid bands through the SAME shared classifier as the service's reads, so it has to + -- feed it the same inputs — left unselected they default to null, this COMPILES, and the + -- grid would call a regressed collector HEALTHY while get_collection_health called it + -- WARNING. That is #3240's lesson and #2804's before it. The FINDING that names the rows and + -- the status is deliberately not carried here: this projection has no rows_stored to count + -- and the grid has no column to render prose in, so the band is the whole of what this + -- surface needs to agree about. APPENDED, read positionally by the one shared mapper. + MAX(CASE WHEN status IS NULL + OR status NOT IN ({CollectorRuntimePrecondition.NamedSkipStatusSqlList}) + THEN collection_time END) AS last_non_skip_time, + MAX(CASE WHEN rows_collected > 0 THEN collection_time END) AS last_productive_time, + -- #4748: the note the collector's NEWEST run left, which is not last_note above (that is the + -- newest run that CARRIED a note, so a clean run after a partial-failure cycle still shows the + -- older cycle's note there). The band reads only this one, because the loss an older note names + -- is not the collector's current state. APPENDED, read positionally by the one shared mapper. + MAX(CASE WHEN recency_rank = 1 AND status = 'SUCCESS' THEN error_message END) AS latest_run_note + FROM + ( + -- #1855: rank each class of message newest-first so the two exemplar columns above can take + -- the LATEST one instead of the lexicographically greatest. Ordering on whether the class's + -- CASE came back empty puts every row that carries such a message ahead of every row that + -- does not, so rank 1 is the newest one that has text — and a later clean run no longer + -- blanks a note the window still holds. MAX() was never wrong about WHICH rows to consider, + -- only about which of them wins, and text does not sort like the number #1837's probe note + -- carries: 12 item(s) sorts below 9 item(s). collection_time settles it; error_message DESC + -- only breaks an exact-timestamp tie, and breaks it identically here and in Lite's DuckDB + -- twin, which binary-vs-locale collation would not. + SELECT + collector_name, + collection_time, + duration_ms, + status, + error_message, + -- #2926: the abandonment predicate above reads it. Projected here for the same reason + -- #2472's three columns are: this subquery ENUMERATES its columns, so an aggregate + -- outside naming one it does not carry fails at the STORE and nowhere earlier. + rows_collected, + ROW_NUMBER() OVER + ( + PARTITION BY collector_name + ORDER BY (CASE WHEN status = 'SUCCESS' THEN error_message END) IS NULL, + collection_time DESC, + error_message DESC + ) AS note_rank, + ROW_NUMBER() OVER + ( + PARTITION BY collector_name + ORDER BY (CASE WHEN status IN ('ERROR', 'PERMISSIONS', 'EXTENSION_MISSING') THEN error_message END) IS NULL, + collection_time DESC, + error_message DESC + ) AS error_rank, + -- #4748: newest run first, so latest_run_note above takes the newest run's note. status DESC + -- only breaks an exact-timestamp tie, the way the Darling service read breaks it. + ROW_NUMBER() OVER + ( + PARTITION BY collector_name + ORDER BY collection_time DESC, + status DESC + ) AS recency_rank + FROM v_collection_log + WHERE server_id = $1 + AND collection_time >= $2 + ) runs + GROUP BY collector_name + ORDER BY collector_name + """; +} diff --git a/Darling/Darling.Tests/CollectionHealthRunTimeFieldsLiveTests.cs b/Darling/Darling.Tests/CollectionHealthRunTimeFieldsLiveTests.cs new file mode 100644 index 0000000000..11d92c6847 --- /dev/null +++ b/Darling/Darling.Tests/CollectionHealthRunTimeFieldsLiveTests.cs @@ -0,0 +1,587 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4938: get_collection_health shows a collector's run time. A collector with a run time carries +/// run_at (24-hour HH:MM on the monitored server's clock) and next_run_utc (the next time it is +/// due, UTC). A collector without one carries null for both on a full row, and nothing for either on a partial or a +/// compact row. The server's row wins over the fleet row, a run time of +/// -1 on the server's row stops the fleet's, and a collector whose interval is not a whole number of days has none. +/// The health band is read from the shipped cadence and a run time does not move it. A server with no clock yet reads +/// the run time as UTC. A day the collector skipped crosses the stale line before the next slot, and the row carries +/// a run_time_note that says so. +/// +/* #1776 own-store: each fact mints its own scratch database through ScratchPostgres and never touches the shared + store's tables, so it cannot race the live collection and serializing it would be pure slowdown. */ +public sealed class CollectionHealthRunTimeFieldsLiveTests +{ + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private const string SkipText = "Set DARLING_TEST_PG to a Postgres connection string to run the run-time health fields' live pins (each mints its own scratch database)."; + + /* Two collectors on a daily interval (the second is an on-load collector the shipped schedule recaptures daily) and + one on a one-minute interval. */ + private const string DailyCollector = "index_object_stats"; + private const string SecondDailyCollector = "server_config"; + private const string MinuteCollector = "wait_stats"; + + /* A run time 12 hours from now, so today's slot and the one before it are both more than 11 hours from now whatever + the server's spread is, and a test never lands inside the 60-minute window after a slot. A fixed 02:00 would sit in + that window for two hours of every day. */ + private static int RunTimeHalfADayAway(DateTime nowUtc) => (nowUtc.Hour * 60 + nowUtc.Minute + 12 * 60) % 1440; + + /* The UTC time of day of a slot: the run time on a clock this many minutes behind UTC, plus the server's spread. */ + private static TimeSpan SlotTimeOfDayUtc(int runAtMinute, int serverId, int minutesBehindUtc = 0) + { + var slot = TimeSpan.FromMinutes(runAtMinute + minutesBehindUtc) + CollectorRunTime.Spread(serverId); + return TimeSpan.FromTicks(slot.Ticks % TimeSpan.FromDays(1).Ticks); + } + + private static async Task MigratedAsync(ScratchPostgres scratch, CancellationToken ct) + { + var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + return connection; + } + + private static async Task RegisterServerAsync(NpgsqlConnection connection, int serverId, string name, CancellationToken ct) + { + await using var command = new NpgsqlCommand( + """ + INSERT INTO servers (server_id, server_name, display_name, is_enabled, sql_major_version, created_date, modified_date) + VALUES ($1, $2, $2, TRUE, 15, $3, $3) + """, connection); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(name); + command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task LogRunAsync(NpgsqlConnection connection, int serverId, string serverName, string collector, DateTime whenUtc, CancellationToken ct) + { + await using var command = new NpgsqlCommand( + """ + INSERT INTO collection_log (log_id, collection_time, server_id, server_name, collector_name, status, duration_ms, rows_collected) + VALUES ($1, $2, $3, $4, $5, 'SUCCESS', 120, 10) + """, connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(whenUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(serverName); + command.Parameters.AddWithValue(collector); + await command.ExecuteNonQueryAsync(ct); + } + + /// One failed run of a collector: an ERROR row in the window, which keeps the collector off the compact shape + /// and gives it the partial one. + private static async Task LogFailureAsync(NpgsqlConnection connection, int serverId, string serverName, string collector, DateTime whenUtc, CancellationToken ct) + { + await using var command = new NpgsqlCommand( + """ + INSERT INTO collection_log (log_id, collection_time, server_id, server_name, collector_name, status, duration_ms, rows_collected, error_message) + VALUES ($1, $2, $3, $4, $5, 'ERROR', 120, 0, 'a failed run') + """, connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(whenUtc, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(serverName); + command.Parameters.AddWithValue(collector); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task PlantOffsetAsync(NpgsqlConnection connection, int serverId, string serverName, int offsetMinutes, CancellationToken ct) + { + await using var command = new NpgsqlCommand( + """ + INSERT INTO server_properties (collection_id, collection_time, server_id, server_name, utc_offset_minutes) + VALUES ($1, $2, $3, $4, $5) + """, connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(serverName); + command.Parameters.AddWithValue(offsetMinutes); + await command.ExecuteNonQueryAsync(ct); + } + + /// One run-time row (config.config_collector_run_times): a fleet row when is null, + /// else that server's. The run time is minutes after midnight, or -1 for "no fixed time". A run time needs no schedule row, so + /// one is written only for a collector that is switched OFF ( false): every other call reads the + /// run time with no schedule row at all. + private static async Task SetRunTimeAsync(NpgsqlConnection connection, int? serverId, string collector, int minute, CancellationToken ct, bool enabled = true) + { + await using var command = new NpgsqlCommand( + "INSERT INTO config.config_collector_run_times (server_id, collector_name, run_at_minute) VALUES ($1, $2, $3)", connection); + command.Parameters.Add(new NpgsqlParameter { Value = (object?)serverId ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Integer }); + command.Parameters.AddWithValue(collector); + command.Parameters.Add(new NpgsqlParameter { Value = (short)minute, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Smallint }); + await command.ExecuteNonQueryAsync(ct); + + if (!enabled) + { + await using var schedule = new NpgsqlCommand( + "INSERT INTO config.config_collector_schedules (server_id, collector_name, enabled) VALUES ($1, $2, FALSE)", connection); + schedule.Parameters.Add(new NpgsqlParameter { Value = (object?)serverId ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Integer }); + schedule.Parameters.AddWithValue(collector); + await schedule.ExecuteNonQueryAsync(ct); + } + } + + private static Task SetFleetRunTimeAsync(NpgsqlConnection connection, string collector, int minute, CancellationToken ct) => + SetRunTimeAsync(connection, null, collector, minute, ct); + + /// The rows of get_collection_health by collector. Full detail by default, because a healthy + /// collector otherwise compacts to seven fields. + private static async Task> ReadRowsAsync(NpgsqlDataSource postgres, string server, CancellationToken ct, bool fullDetail = true) + { + var json = await DarlingMcpDataTools.GetCollectionHealth(postgres, server, full_detail: fullDetail, cancellationToken: ct); + using var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.TryGetProperty("collectors", out var collectors), json.Length > 400 ? json[..400] : json); + return collectors.EnumerateArray().ToDictionary(r => r.GetProperty("collector").GetString()!, r => r.Clone()); + } + + private static DateTime ParseUtc(JsonElement value) + { + var parsed = DateTime.Parse(value.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + Assert.Equal(DateTimeKind.Utc, parsed.Kind); + return parsed; + } + + [Fact] + public async Task ACollectorWithARunTime_ShowsRunAtAndNextRunUtc_AndACollectorWithoutOneShowsNullForBoth() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_001; + const string server = "runtime-health-a"; + await RegisterServerAsync(connection, serverId, server, ct); + + /* Both ran a minute ago, so the daily collector's next slot is the one ahead of now. */ + var now = DateTime.UtcNow; + var runAt = RunTimeHalfADayAway(now); + await LogRunAsync(connection, serverId, server, DailyCollector, now.AddMinutes(-1), ct); + await LogRunAsync(connection, serverId, server, MinuteCollector, now.AddMinutes(-1), ct); + await SetFleetRunTimeAsync(connection, DailyCollector, runAt, ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var rows = await ReadRowsAsync(postgres, server, ct); + + var daily = rows[DailyCollector]; + Assert.Equal(CollectorRunTime.Format(runAt), daily.GetProperty("run_at").GetString()); + var next = ParseUtc(daily.GetProperty("next_run_utc")); + Assert.True(next > now, $"the next run {next:o} must be ahead of {now:o}"); + Assert.True(next <= now + CollectorRunTime.MaxStampAhead(1440), $"the next run {next:o} is more than a day and the spread away"); + + /* No clock for this server, so the run time reads as UTC: the run time plus its fixed spread. */ + Assert.Equal(SlotTimeOfDayUtc(runAt, serverId), next.TimeOfDay); + + /* Nothing was skipped: it ran a minute ago. */ + Assert.Equal(JsonValueKind.Null, daily.GetProperty("run_time_note").ValueKind); + + var minute = rows[MinuteCollector]; + Assert.Equal(JsonValueKind.Null, minute.GetProperty("run_at").ValueKind); + Assert.Equal(JsonValueKind.Null, minute.GetProperty("next_run_utc").ValueKind); + Assert.Equal(JsonValueKind.Null, minute.GetProperty("run_time_note").ValueKind); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task ARunTimeIsOnTheServersClock_AndAServerWithNoClockReadsItAsUtc() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int plainId = 480_011; + const int offsetId = 480_012; + const string plain = "runtime-health-utc"; + const string offset = "runtime-health-west"; + await RegisterServerAsync(connection, plainId, plain, ct); + await RegisterServerAsync(connection, offsetId, offset, ct); + + var now = DateTime.UtcNow; + var runAt = RunTimeHalfADayAway(now); + await LogRunAsync(connection, plainId, plain, DailyCollector, now.AddMinutes(-1), ct); + await LogRunAsync(connection, offsetId, offset, DailyCollector, now.AddMinutes(-1), ct); + + /* Five hours behind UTC, a fixed offset: its run time is five hours later in UTC. */ + await PlantOffsetAsync(connection, offsetId, offset, -300, ct); + await SetFleetRunTimeAsync(connection, DailyCollector, runAt, ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var plainRow = (await ReadRowsAsync(postgres, plain, ct))[DailyCollector]; + var offsetRow = (await ReadRowsAsync(postgres, offset, ct))[DailyCollector]; + + /* The run time is the same text on both: it is each server's own wall clock. */ + Assert.Equal(CollectorRunTime.Format(runAt), plainRow.GetProperty("run_at").GetString()); + Assert.Equal(CollectorRunTime.Format(runAt), offsetRow.GetProperty("run_at").GetString()); + Assert.Equal(SlotTimeOfDayUtc(runAt, plainId), ParseUtc(plainRow.GetProperty("next_run_utc")).TimeOfDay); + Assert.Equal(SlotTimeOfDayUtc(runAt, offsetId, minutesBehindUtc: 300), ParseUtc(offsetRow.GetProperty("next_run_utc")).TimeOfDay); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task AServerRunTimeWinsOverTheFleetRunTime_MinusOneOnTheServerStopsIt_AndAnIntervalThatIsNotWholeDaysHasNone() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int ownId = 480_031; + const int noneId = 480_032; + const int fleetId = 480_033; + const string own = "runtime-health-own"; + const string none = "runtime-health-none"; + const string fleetOnly = "runtime-health-fleet"; + var now = DateTime.UtcNow; + foreach (var (id, name) in new[] { (ownId, own), (noneId, none), (fleetId, fleetOnly) }) + { + await RegisterServerAsync(connection, id, name, ct); + await LogRunAsync(connection, id, name, DailyCollector, now.AddMinutes(-1), ct); + await LogRunAsync(connection, id, name, MinuteCollector, now.AddMinutes(-1), ct); + } + + /* The fleet runs the daily collector at 02:00. One server has its own 04:30, and one has -1, which means no + fixed time there and stops the fleet's 02:00 from applying. The fleet row also names the one-minute + collector, which a run time cannot apply to. */ + await SetFleetRunTimeAsync(connection, DailyCollector, 120, ct); + await SetFleetRunTimeAsync(connection, MinuteCollector, 120, ct); + await SetRunTimeAsync(connection, ownId, DailyCollector, 270, ct); + await SetRunTimeAsync(connection, noneId, DailyCollector, -1, ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var ownRows = await ReadRowsAsync(postgres, own, ct); + var noneRows = await ReadRowsAsync(postgres, none, ct); + var fleetRows = await ReadRowsAsync(postgres, fleetOnly, ct); + + Assert.Equal("04:30", ownRows[DailyCollector].GetProperty("run_at").GetString()); + Assert.Equal(JsonValueKind.String, ownRows[DailyCollector].GetProperty("next_run_utc").ValueKind); + + Assert.Equal(JsonValueKind.Null, noneRows[DailyCollector].GetProperty("run_at").ValueKind); + Assert.Equal(JsonValueKind.Null, noneRows[DailyCollector].GetProperty("next_run_utc").ValueKind); + + Assert.Equal("02:00", fleetRows[DailyCollector].GetProperty("run_at").GetString()); + + foreach (var rows in new[] { ownRows, noneRows, fleetRows }) + { + Assert.Equal(JsonValueKind.Null, rows[MinuteCollector].GetProperty("run_at").ValueKind); + Assert.Equal(JsonValueKind.Null, rows[MinuteCollector].GetProperty("next_run_utc").ValueKind); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task ADisabledCollectorWithARunTime_ShowsItsRunTimeAndNoNextRun() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_041; + const string server = "runtime-health-off"; + await RegisterServerAsync(connection, serverId, server, ct); + await LogRunAsync(connection, serverId, server, DailyCollector, DateTime.UtcNow.AddMinutes(-1), ct); + await SetRunTimeAsync(connection, null, DailyCollector, 120, ct, enabled: false); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var row = (await ReadRowsAsync(postgres, server, ct))[DailyCollector]; + + Assert.Equal("02:00", row.GetProperty("run_at").GetString()); + Assert.Equal(JsonValueKind.Null, row.GetProperty("next_run_utc").ValueKind); + Assert.Equal(JsonValueKind.Null, row.GetProperty("run_time_note").ValueKind); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task ACompactRowCarriesTheRunTimeOnlyWhenTheCollectorHasOne() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_051; + const string server = "runtime-health-compact"; + await RegisterServerAsync(connection, serverId, server, ct); + var now = DateTime.UtcNow; + await LogRunAsync(connection, serverId, server, DailyCollector, now.AddMinutes(-1), ct); + await LogRunAsync(connection, serverId, server, MinuteCollector, now.AddMinutes(-1), ct); + var runAt = RunTimeHalfADayAway(now); + await SetFleetRunTimeAsync(connection, DailyCollector, runAt, ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var rows = await ReadRowsAsync(postgres, server, ct, fullDetail: false); + + /* Both are healthy with nothing to report, so both compact. A compact row stays small: it carries the run + time only for the collector that has one, and never the note, which a healthy row cannot need. */ + var daily = rows[DailyCollector]; + Assert.True(daily.GetProperty("compact").GetBoolean()); + Assert.Equal(CollectorRunTime.Format(runAt), daily.GetProperty("run_at").GetString()); + Assert.Equal(JsonValueKind.String, daily.GetProperty("next_run_utc").ValueKind); + Assert.False(daily.TryGetProperty("run_time_note", out _)); + + var minute = rows[MinuteCollector]; + Assert.True(minute.GetProperty("compact").GetBoolean()); + Assert.False(minute.TryGetProperty("run_at", out _)); + Assert.False(minute.TryGetProperty("next_run_utc", out _)); + Assert.False(minute.TryGetProperty("run_time_note", out _)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task APartialRowCarriesTheRunTimeOnlyWhenTheCollectorHasOne() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_061; + const string server = "runtime-health-partial"; + await RegisterServerAsync(connection, serverId, server, ct); + var now = DateTime.UtcNow; + + /* A success and then a failure for each collector: an error in the window fails the compact test, so both + take the partial shape. */ + foreach (var collector in new[] { DailyCollector, MinuteCollector }) + { + await LogRunAsync(connection, serverId, server, collector, now.AddMinutes(-2), ct); + await LogFailureAsync(connection, serverId, server, collector, now.AddMinutes(-1), ct); + } + + var runAt = RunTimeHalfADayAway(now); + await SetFleetRunTimeAsync(connection, DailyCollector, runAt, ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var rows = await ReadRowsAsync(postgres, server, ct, fullDetail: false); + + /* Lite's partial row carries the run time only for a collector that has one, so a partial row stays as + small as the rest of its keys and the two apps give a caller the same shape. The note rides under the same + condition: a partial row for a collector with a run time carries it (null unless a day was skipped), and a + partial row for a collector with none has no run_time_note key at all. */ + var daily = rows[DailyCollector]; + Assert.True(daily.GetProperty("partial_detail").GetBoolean()); + Assert.Equal(CollectorRunTime.Format(runAt), daily.GetProperty("run_at").GetString()); + Assert.Equal(JsonValueKind.String, daily.GetProperty("next_run_utc").ValueKind); + Assert.Equal(JsonValueKind.Null, daily.GetProperty("run_time_note").ValueKind); + + var minute = rows[MinuteCollector]; + Assert.True(minute.GetProperty("partial_detail").GetBoolean()); + Assert.False(minute.TryGetProperty("run_at", out _)); + Assert.False(minute.TryGetProperty("next_run_utc", out _)); + Assert.False(minute.TryGetProperty("run_time_note", out _)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task TheHealthBand_IsTheSameWithAndWithoutARunTime_AndTheRunTimeIsNotHeldByTheHealthMemo() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_021; + const string server = "runtime-health-band"; + await RegisterServerAsync(connection, serverId, server, ct); + + /* A daily collector that last ran 40 hours ago is past the 36-hour stale line. Skipping a day is a real + miss and the row says so; a run time does not excuse it. */ + await LogRunAsync(connection, serverId, server, DailyCollector, DateTime.UtcNow.AddHours(-40), ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var before = (await ReadRowsAsync(postgres, server, ct))[DailyCollector]; + Assert.Equal(JsonValueKind.Null, before.GetProperty("run_at").ValueKind); + Assert.Equal(CollectorHealthClassifier.Stale, before.GetProperty("status").GetString()); + + /* The health rows are memoized for the call that follows. The run time is read fresh, so an edit shows. */ + var runAt = RunTimeHalfADayAway(DateTime.UtcNow); + await SetFleetRunTimeAsync(connection, DailyCollector, runAt, ct); + var after = (await ReadRowsAsync(postgres, server, ct))[DailyCollector]; + + Assert.Equal(CollectorHealthClassifier.Stale, after.GetProperty("status").GetString()); + Assert.Equal(CollectorRunTime.Format(runAt), after.GetProperty("run_at").GetString()); + Assert.Equal(JsonValueKind.String, after.GetProperty("next_run_utc").ValueKind); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task ASkippedDay_CrossesTheStaleLineBeforeTheNextSlot_AndTheRowSaysSo() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_061; + const string server = "runtime-health-skipped"; + await RegisterServerAsync(connection, serverId, server, ct); + + /* The daily collector last ran 40 hours ago, so it missed a day and is past its 36-hour stale line, and its + next slot is half a day away. The second daily collector ran a minute ago: nothing was skipped there. */ + var now = DateTime.UtcNow; + var runAt = RunTimeHalfADayAway(now); + await LogRunAsync(connection, serverId, server, DailyCollector, now.AddHours(-40), ct); + await LogRunAsync(connection, serverId, server, SecondDailyCollector, now.AddMinutes(-1), ct); + await SetFleetRunTimeAsync(connection, DailyCollector, runAt, ct); + await SetFleetRunTimeAsync(connection, SecondDailyCollector, runAt, ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var rows = await ReadRowsAsync(postgres, server, ct); + + var skipped = rows[DailyCollector]; + Assert.Equal(CollectorHealthClassifier.Stale, skipped.GetProperty("status").GetString()); + var next = ParseUtc(skipped.GetProperty("next_run_utc")); + Assert.True(next > now, "the next slot is still ahead of the stale reading"); + var note = skipped.GetProperty("run_time_note").GetString(); + Assert.NotNull(note); + Assert.Contains("Skipped day", note, StringComparison.Ordinal); + Assert.Contains("36-hour stale line", note, StringComparison.Ordinal); + Assert.Contains(next.ToString("u", CultureInfo.InvariantCulture), note, StringComparison.Ordinal); + Assert.Contains("not replayed", note, StringComparison.Ordinal); + + var onTime = rows[SecondDailyCollector]; + Assert.Equal(CollectorHealthClassifier.Healthy, onTime.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Null, onTime.GetProperty("run_time_note").ValueKind); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task ACollectorDueNowInsideItsGrace_HasNoSkippedDayNote_EvenPastTheStaleLine() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + var bodySucceeded = false; + try + { + const int serverId = 480_071; + const string server = "runtime-health-due"; + await RegisterServerAsync(connection, serverId, server, ct); + + /* A run time whose slot for this server passed about ten minutes ago: now is inside the 60-minute grace, so + the collector is due now. It last ran 40 hours ago, but a run is a moment away, so there is no note. */ + var now = DateTime.UtcNow; + var spreadMinutes = (int)CollectorRunTime.Spread(serverId).TotalMinutes; + var runAt = ((now.Hour * 60 + now.Minute - spreadMinutes - 10) % 1440 + 1440) % 1440; + await LogRunAsync(connection, serverId, server, DailyCollector, now.AddHours(-40), ct); + await SetFleetRunTimeAsync(connection, DailyCollector, runAt, ct); + + await using var postgres = NpgsqlDataSource.Create(scratch.ConnectionString); + var row = (await ReadRowsAsync(postgres, server, ct))[DailyCollector]; + + Assert.Equal(CollectorHealthClassifier.Stale, row.GetProperty("status").GetString()); + var next = ParseUtc(row.GetProperty("next_run_utc")); + Assert.True(Math.Abs((next - DateTime.UtcNow).TotalMinutes) < 2, $"due now: the next run {next:o} is the time of the read"); + Assert.Equal(JsonValueKind.Null, row.GetProperty("run_time_note").ValueKind); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } +} diff --git a/Darling/Darling.Tests/CollectionHealthScheduleOverridesLiveTests.cs b/Darling/Darling.Tests/CollectionHealthScheduleOverridesLiveTests.cs new file mode 100644 index 0000000000..6c26da7eb0 --- /dev/null +++ b/Darling/Darling.Tests/CollectionHealthScheduleOverridesLiveTests.cs @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Common; +using PerformanceMonitor.Darling.Service.Mcp; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4999: the collection-health reads judge each collector against the interval it is scheduled at on its server, read +/// from config.config_collector_schedules. The schedule read fails open to the shipped cadences (a collection +/// health read must still answer when it cannot read the table), so every other test of the health reads would +/// still pass if DarlingDataReader.ScheduleOverridesSql or DarlingDataReader.AllScheduleOverridesSql broke. +/// This one seeds the rows and reads the health back, so a read that falls back shows up as the wrong band. +/// +/// Three five-minute collectors, each with a success six hours old: past the four hours a five-minute collector +/// goes STALE at, well inside the 18 hours (one and a half intervals) a collector scheduled every 720 minutes gets. +/// The first has a server row (720) AND a fleet row (5): the server row wins, so HEALTHY. The second has only a fleet +/// row (720): HEALTHY. The third has no row: the shipped five minutes, so STALE. The same server is read through the +/// MCP read and through the viewer's server tab and its fleet breakdown, and, in the second test, through the +/// service's fleet roll-up, which reads every server's override rows in one statement. +/// +[Collection("live-postgres")] +public sealed class CollectionHealthScheduleOverridesLiveTests +{ + private const string ServerName = "darling-collection-health-schedule-overrides-e2e"; + private static readonly int ServerId = ServerIdHelper.GetDeterministicHashCode(ServerName); + + private const string ServerRowWins = "memory_clerks"; + private const string FleetRowApplies = "memory_pressure_events"; + private const string ShippedDefault = "session_summary_stats"; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + [Fact] + public async Task EachCollectorIsJudgedAgainstTheServerRow_ThenTheFleetRow_ThenTheShippedDefault() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to run the live collection-health schedule-override test."); + var ct = TestContext.Current.CancellationToken; + + await using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteRowsAsync(connection, ct); + + await using var postgres = NpgsqlDataSource.Create(cs!); + var bodySucceeded = false; + try + { + await SeedAsync(connection, ct); + + /* The MCP per-server read. */ + var mcp = await DarlingDataReader.GetCollectionHealthAsync( + postgres, ServerId, DarlingMcpTestData.Naive(DateTime.UtcNow.AddDays(-7)), ct); + AssertJudged("MCP", mcp.ToDictionary(r => r.CollectorName, r => (r.FrequencyMinutes, r.HealthStatus))); + + /* The viewer's server tab, and the per-server breakdown the Overview cards and status bar count. */ + await using var viewer = new ViewerDataService(cs!); + var tab = await viewer.GetCollectionHealthAsync(ServerId, ct); + AssertJudged("viewer server tab", tab.ToDictionary(r => r.CollectorName, r => (r.EffectiveFrequencyMinutes ?? 0, r.HealthStatus))); + + var byServer = await viewer.GetFleetCollectionHealthByServerAsync(ct); + AssertJudged("viewer fleet breakdown", byServer[ServerId].ToDictionary(r => r.CollectorName, r => (r.EffectiveFrequencyMinutes ?? 0, r.HealthStatus))); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + /// + /// The service's fleet roll-up bands every server's collectors in one read, so it reads every override row + /// (DarlingDataReader.AllScheduleOverridesSql) and picks each server's out of them. Same seed as the test + /// above: the two collectors with a 720-minute row, the server's own and the fleet-wide one, are HEALTHY, and the + /// one on its shipped five minutes is STALE. A roll-up that read no overrides would band all three STALE and count + /// none healthy; one that dropped either level would count one. + /// + [Fact] + public async Task TheFleetRollUpBandsEachCollectorByTheIntervalItIsScheduledAt() + { + var cs = ConnectionString; + Assert.SkipWhen(string.IsNullOrEmpty(cs), "Set DARLING_TEST_PG to run the live collection-health schedule-override test."); + var ct = TestContext.Current.CancellationToken; + + await using var connection = new NpgsqlConnection(cs); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + await DeleteRowsAsync(connection, ct); + + await using var postgres = NpgsqlDataSource.Create(cs!); + var bodySucceeded = false; + try + { + await SeedAsync(connection, ct); + + var counts = await ReadFleetRollUpCountsAsync(postgres, ct); + + Assert.Equal(3, counts.Total); + Assert.Equal(2, counts.Healthy); + Assert.Equal(0, counts.Failing); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(cs!, bodySucceeded, async (cleanup, cleanupCt) => await DeleteRowsAsync(cleanup, cleanupCt)); + } + } + + /// The product's fleet roll-up read, through its own method (ReadFailingCollectorCountsAsync is private; + /// reached by reflection so the test takes no seam), for the one server this test seeds. + private static async Task ReadFleetRollUpCountsAsync(NpgsqlDataSource postgres, CancellationToken ct) + { + var method = typeof(DarlingFleetReader).GetMethod("ReadFailingCollectorCountsAsync", BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("ReadFailingCollectorCountsAsync not found"); + var counts = await (Task>)method.Invoke(null, [postgres, DateTime.UtcNow, ct])!; + return counts[ServerId]; + } + + /// The server, its three collectors' runs and the two levels of schedule override both tests read. + private static async Task SeedAsync(NpgsqlConnection connection, CancellationToken ct) + { + await DarlingMcpTestData.RegisterServerAsync(connection, ServerId, ServerName, ct); + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO config_monitored_servers (server_id, name, host, is_enabled) VALUES ($1, $2, $2, TRUE) +ON CONFLICT (server_id) DO UPDATE SET is_enabled = TRUE", ServerId, ServerName); + + var now = DateTime.UtcNow; + foreach (var collector in new[] { ServerRowWins, FleetRowApplies, ShippedDefault }) + { + for (var i = 0; i < 10; i++) + { + await InsertSuccessAsync(connection, collector, now.AddHours(-6).AddMinutes(-5 * i), ct); + } + } + + /* The server's own row beats the fleet's; the fleet's row alone applies; the third has neither. */ + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled) +VALUES ($1, $2, 720, NULL, TRUE)", ServerId, ServerRowWins); + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled) +VALUES (NULL, $1, 5, NULL, TRUE)", ServerRowWins); + await DarlingMcpTestData.ExecAsync(connection, ct, @" +INSERT INTO config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled) +VALUES (NULL, $1, 720, NULL, TRUE)", FleetRowApplies); + + Assert.Equal(5, CollectorScheduleDefaults.All[ServerRowWins].FrequencyMinutes); + Assert.Equal(5, CollectorScheduleDefaults.All[FleetRowApplies].FrequencyMinutes); + Assert.Equal(5, CollectorScheduleDefaults.All[ShippedDefault].FrequencyMinutes); + } + + private static void AssertJudged(string surface, Dictionary rows) + { + Assert.True(rows.TryGetValue(ServerRowWins, out var server), $"{surface}: {ServerRowWins} is missing"); + Assert.Equal((720, CollectorHealthClassifier.Healthy), server); + + Assert.True(rows.TryGetValue(FleetRowApplies, out var fleet), $"{surface}: {FleetRowApplies} is missing"); + Assert.Equal((720, CollectorHealthClassifier.Healthy), fleet); + + Assert.True(rows.TryGetValue(ShippedDefault, out var shipped), $"{surface}: {ShippedDefault} is missing"); + Assert.Equal((5, CollectorHealthClassifier.Stale), shipped); + } + + private static async Task InsertSuccessAsync(NpgsqlConnection connection, string collectorName, DateTime collectionTime, CancellationToken ct) + { + await using var command = new NpgsqlCommand(@" +INSERT INTO collection_log (log_id, collection_time, server_id, server_name, collector_name, status, duration_ms, rows_collected, error_message) +VALUES ($1,$2,$3,$4,$5,'SUCCESS',20,10,NULL)", connection); + command.Parameters.AddWithValue(CollectionIdGenerator.Next()); + command.Parameters.AddWithValue(DateTime.SpecifyKind(collectionTime, DateTimeKind.Unspecified)); + command.Parameters.AddWithValue(ServerId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(collectorName); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task DeleteRowsAsync(NpgsqlConnection connection, CancellationToken ct) + { + await using var cleanup = new NpgsqlCommand( + $"DELETE FROM collection_log WHERE server_id = {ServerId}; " + + $"DELETE FROM config_collector_schedules WHERE collector_name IN ('{ServerRowWins}', '{FleetRowApplies}', '{ShippedDefault}') AND (server_id IS NULL OR server_id = {ServerId}); " + + $"DELETE FROM config_monitored_servers WHERE server_id = {ServerId}; " + + $"DELETE FROM servers WHERE server_id = {ServerId};", + connection); + await cleanup.ExecuteNonQueryAsync(ct); + } +} diff --git a/Darling/Darling.Tests/CollectionHistoryAuditTests.cs b/Darling/Darling.Tests/CollectionHistoryAuditTests.cs new file mode 100644 index 0000000000..077e0b5754 --- /dev/null +++ b/Darling/Darling.Tests/CollectionHistoryAuditTests.cs @@ -0,0 +1,1401 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Alerting; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Notifications; +using Xunit; +using Rollup = PerformanceMonitor.Darling.Service.CollectionHistoryAudit.Rollup; +using HourBucket = PerformanceMonitor.Darling.Service.CollectionHistoryAudit.HourBucket; + +namespace Darling.Tests; + +/// +/// #5450 proposal 3: the daily retained-history audit. Pure rules (hour-of-day medians, the half test on each +/// count, range merging, the three-day minimum), the evaluator's once-a-day and one-alert rules, and live reads +/// of a seeded rollup. +/// +/* #1776 own-store: deliberately NOT [Collection("live-postgres")]. The live tests reach DARLING_TEST_PG only to + CREATE and DROP their own database through ScratchPostgres and work entirely inside it. */ +public sealed class CollectionHistoryAuditTests +{ + private static readonly CancellationToken Ct = TestContext.Current.CancellationToken; + private static readonly DateTime AuditDay = new(2026, 10, 10, 0, 0, 0, DateTimeKind.Utc); + + /// A workload rollup: judged on servers only. + private static readonly Rollup QueryStats = new("collect.query_stats_interval_hourly", false); + + /// The perfmon baseline: judged on servers and collection passes. + private static readonly Rollup Perfmon = new("collect.perfmon_interval_baseline", true); + + /// The normal load curve: servers flat, passes climbing with the hour, so a pass test that + /// ignored the hour of day would be wrong somewhere. + private static (int Servers, long Passes) Usual(int hour) => (40, 1000 + (hour * 100)); + + private static IEnumerable Day( + DateTime day, Func shape) + { + for (var hour = 0; hour < 24; hour++) + { + if (shape(hour) is { } v) + { + yield return new HourBucket(day.AddHours(hour), v.Servers, v.Passes); + } + } + } + + private static List Week(int priorDays = 7) => + Enumerable.Range(1, priorDays) + .SelectMany(d => Day(AuditDay.AddDays(-d), h => Usual(h))) + .ToList(); + + private static List WithAuditDay(List week, Func shape) + { + week.AddRange(Day(AuditDay, shape)); + return week; + } + + [Fact] + public void ANormalDay_FlagsNothing() + { + var buckets = WithAuditDay(Week(), h => Usual(h)); + var ranges = CollectionHistoryAudit.Analyze(AuditDay, buckets); + Assert.NotNull(ranges); + Assert.Empty(ranges!); + } + + [Fact] + public void AThreeHourHole_IsOneRange_WithTheCountsAgainstUsual() + { + var buckets = WithAuditDay(Week(), h => h is >= 18 and <= 20 ? null : Usual(h)); + var range = Assert.Single(CollectionHistoryAudit.Analyze(AuditDay, buckets)!); + Assert.Equal(18, range.StartHour); + Assert.Equal(21, range.EndHour); + Assert.Equal("18:00-21:00Z", CollectionHistoryAudit.RangeLabel(range)); + Assert.Equal(0, range.LowestServers); + Assert.Equal(40, range.UsualServers); + Assert.Equal(0, range.LowestPasses); + } + + [Fact] + public void PassesAtTwentyPercent_WithEveryServerPresent_IsFlagged() + { + /* Case 2 of the issue: rows arrived every hour from every server and only the volume fell. */ + var buckets = WithAuditDay(Week(), h => h is >= 5 and <= 7 ? (40, Usual(h).Passes / 5) : Usual(h)); + var range = Assert.Single(CollectionHistoryAudit.Analyze(AuditDay, buckets)!); + Assert.Equal("05:00-08:00Z", CollectionHistoryAudit.RangeLabel(range)); + Assert.Equal(40, range.LowestServers); + Assert.Equal(300, range.LowestPasses); + Assert.Equal(1500, range.UsualPasses); + } + + [Fact] + public void AWorkloadSourceCarriesNoPasses_SoAQuietWeekendWithEveryServerPresentIsClean() + { + /* H2 of the #5461 review: a workload rollup is read for servers only (passes are 0 in every bucket), so a + weekend with a fraction of the queries but every server present flags nothing. */ + var week = Enumerable.Range(1, 7).SelectMany(d => Day(AuditDay.AddDays(-d), h => (40, 0L))).ToList(); + var buckets = WithAuditDay(week, h => (40, 0L)); + Assert.Empty(CollectionHistoryAudit.Analyze(AuditDay, buckets)!); + + /* ... and the hole in servers is still found. */ + var holey = WithAuditDay(Enumerable.Range(1, 7).SelectMany(d => Day(AuditDay.AddDays(-d), h => (40, 0L))).ToList(), + h => h == 9 ? (10, 0L) : (40, 0L)); + Assert.Equal("09:00-10:00Z", CollectionHistoryAudit.RangeLabel(Assert.Single(CollectionHistoryAudit.Analyze(AuditDay, holey)!))); + } + + [Theory] + [InlineData(19, false)] // 19 of 40 servers: under half + [InlineData(20, true)] // exactly half is not under half + public void TheHalfTest_IsStrict_OnServersAlone(int servers, bool clean) + { + var buckets = WithAuditDay(Week(), h => h == 5 ? (servers, Usual(5).Passes) : Usual(h)); + Assert.Equal(clean, CollectionHistoryAudit.Analyze(AuditDay, buckets)!.Count == 0); + } + + [Theory] + [InlineData(749, false)] // passes 749 of 1500: under half + [InlineData(750, true)] // exactly half + public void TheHalfTest_IsStrict_OnPassesAlone(long passes, bool clean) + { + var buckets = WithAuditDay(Week(), h => h == 5 ? (40, passes) : Usual(h)); + Assert.Equal(clean, CollectionHistoryAudit.Analyze(AuditDay, buckets)!.Count == 0); + } + + [Fact] + public void AnHourWhoseUsualIsUnderOne_NeverFlags() + { + /* L3 of the #5461 review: four prior days of [0, 0, 1, 1] servers is a median of 0.5, and an actual of 0 is under + half of it, but half a server is not a usual worth judging. Usual 1 (two of the four days at 1, the rest + above) is. */ + var prior = new[] { 0, 0, 1, 1 }; + var buckets = new List(); + for (var d = 0; d < 4; d++) + { + buckets.AddRange(Day(AuditDay.AddDays(-(d + 1)), h => (h == 7 ? prior[d] : 5, 0L))); + } + + buckets.AddRange(Day(AuditDay, h => h == 7 ? (0, 0L) : (5, 0L))); + Assert.Empty(CollectionHistoryAudit.Analyze(AuditDay, buckets)!); + + /* Usual exactly 1 with an actual of 0 still flags. */ + var one = new List(); + for (var d = 1; d <= 4; d++) + { + one.AddRange(Day(AuditDay.AddDays(-d), h => (h == 7 ? 1 : 5, 0L))); + } + + one.AddRange(Day(AuditDay, h => h == 7 ? (0, 0L) : (5, 0L))); + Assert.Equal("07:00-08:00Z", CollectionHistoryAudit.RangeLabel(Assert.Single(CollectionHistoryAudit.Analyze(AuditDay, one)!))); + } + + [Fact] + public void UsualIsTheMedianForThatHour_SoOneBadPriorDayDoesNotMoveIt() + { + var week = Week(); + /* One prior day with a hole at hour 12: the median over seven days is still the normal value. */ + week.RemoveAll(b => b.HourUtc == AuditDay.AddDays(-3).AddHours(12)); + var buckets = WithAuditDay(week, h => Usual(h)); + Assert.Empty(CollectionHistoryAudit.Analyze(AuditDay, buckets)!); + Assert.Equal(2.0, CollectionHistoryAudit.Median(new long[] { 1, 2, 3 })); + Assert.Equal(2.5, CollectionHistoryAudit.Median(new long[] { 4, 1, 3, 2 })); + Assert.Equal(0, CollectionHistoryAudit.Median(Array.Empty())); + } + + [Fact] + public void FewerThanThreePriorDaysWithData_IsSkipped_NotAudited() + { + var buckets = WithAuditDay(Week(priorDays: 2), h => null); + Assert.Null(CollectionHistoryAudit.Analyze(AuditDay, buckets)); + + /* Three is enough, and the audited day's own hole is then found. */ + var three = WithAuditDay(Week(priorDays: 3), h => h == 9 ? null : Usual(h)); + var range = Assert.Single(CollectionHistoryAudit.Analyze(AuditDay, three)!); + Assert.Equal(9, range.StartHour); + Assert.Equal(10, range.EndHour); + } + + [Fact] + public void APriorDayWithNoRowsAtAll_IsNotADayOfUsual() + { + /* Prior days 1-3 empty (a store that was not building the source yet), days 4-7 normal: usual is the + normal value, not zero, so the hole on the audited day is still found. */ + var buckets = Enumerable.Range(4, 4).SelectMany(d => Day(AuditDay.AddDays(-d), h => Usual(h))).ToList(); + buckets.AddRange(Day(AuditDay, h => h == 3 ? null : Usual(h))); + var range = Assert.Single(CollectionHistoryAudit.Analyze(AuditDay, buckets)!); + Assert.Equal(3, range.StartHour); + } + + [Fact] + public void SeparateRuns_AreSeparateRanges_AndTheLastHourIsAlwaysJudged() + { + var buckets = WithAuditDay(Week(), h => h is 2 or 3 or 10 or 23 ? null : Usual(h)); + var ranges = CollectionHistoryAudit.Analyze(AuditDay, buckets)!; + Assert.Equal(new[] { "02:00-04:00Z", "10:00-11:00Z", "23:00-24:00Z" }, ranges.Select(CollectionHistoryAudit.RangeLabel)); + } + + [Fact] + public void ASourceIsSettled_OnlyWhenItsNewestBucketReachesTheDaysLastHour() + { + var through22 = WithAuditDay(Week(), h => h <= 22 ? Usual(h) : null); + Assert.False(CollectionHistoryAudit.IsSettled(AuditDay, through22)); + + /* The 23:00 bucket, or any later one (the read runs an hour into the next day), settles it. */ + Assert.True(CollectionHistoryAudit.IsSettled(AuditDay, WithAuditDay(Week(), h => Usual(h)))); + var holeAt23 = WithAuditDay(Week(), h => h == 23 ? null : Usual(h)); + holeAt23.Add(new HourBucket(AuditDay.AddDays(1), 40, 1000)); + Assert.True(CollectionHistoryAudit.IsSettled(AuditDay, holeAt23)); + Assert.False(CollectionHistoryAudit.IsSettled(AuditDay, new List())); + + Assert.Equal(AuditDay.AddDays(1).AddHours(1), CollectionHistoryAudit.ReadTo(AuditDay)); + Assert.Equal(TimeSpan.FromHours(3), CollectionHistoryAudit.DueTimeOfDay); + } + + [Fact] + public void TheReadRunsUpToTheCurrentHour_NeverEarlierThanOneHourPastTheAuditedDay() + { + /* H1 of the #5461 round 2 review: the read ends at the current hour, so a bucket after a hole that runs past + 01:00Z is in it. A bucket at 01:00Z or 02:00Z settles a source whose 23:00 bucket is missing. */ + var tick = new DateTime(2026, 10, 11, 3, 5, 0, DateTimeKind.Utc); + Assert.Equal(new DateTime(2026, 10, 11, 3, 0, 0, DateTimeKind.Utc), CollectionHistoryAudit.ReadTo(AuditDay, tick)); + Assert.Equal(DateTimeKind.Utc, CollectionHistoryAudit.ReadTo(AuditDay, tick).Kind); + Assert.Equal(AuditDay.AddDays(2).AddHours(3), CollectionHistoryAudit.ReadTo(AuditDay, AuditDay.AddDays(2).AddHours(3).AddMinutes(59))); + + /* Never earlier than the day's own end plus one hour, whatever the clock says. */ + Assert.Equal(CollectionHistoryAudit.ReadTo(AuditDay), CollectionHistoryAudit.ReadTo(AuditDay, AuditDay.AddHours(5))); + + var holeThroughMidnight = WithAuditDay(Week(), h => h == 23 ? null : Usual(h)); + holeThroughMidnight.Add(new HourBucket(AuditDay.AddDays(1).AddHours(2), 40, Usual(2).Passes)); + Assert.True(CollectionHistoryAudit.IsSettled(AuditDay, holeThroughMidnight)); + } + + [Fact] + public void TheFlaggedHours_AreTheDistinctHoursOfTheDay_NotHoursTimesSources() + { + /* L2 of the #5461 round 2 review: one three-hour outage that thins every source is three hours. */ + var shared = new[] { new CollectionHistoryAudit.FlaggedRange(18, 21, 0, 40, 0, 0) }; + var overlapping = new[] { new CollectionHistoryAudit.FlaggedRange(20, 22, 0, 40, 0, 0) }; + var sources = Enumerable.Range(0, 7).Select(i => new Rollup("collect.s" + i, false)).ToArray(); + + Assert.Equal(3, CollectionHistoryAudit.FlaggedHours(sources.Select(r => (r, (IReadOnlyList)shared)))); + Assert.Equal(4, CollectionHistoryAudit.FlaggedHours(new (Rollup, IReadOnlyList)[] + { + (sources[0], shared), (sources[1], overlapping), + })); + } + + [Fact] + public void TheSql_HasARangePredicateOnBucket_AndCountsOnlyServersEnabledNow() + { + var servers = CollectionHistoryAudit.BuildSql(QueryStats); + Assert.Contains("FROM collect.query_stats_interval_hourly AS r", servers, StringComparison.Ordinal); + Assert.Contains("WHERE r.bucket >= $1", servers, StringComparison.Ordinal); + Assert.Contains("r.bucket < $2", servers, StringComparison.Ordinal); + Assert.Contains("COUNT(DISTINCT r.server_id)", servers, StringComparison.Ordinal); + Assert.Contains("0::bigint", servers, StringComparison.Ordinal); + Assert.DoesNotContain("sample_count", servers, StringComparison.Ordinal); + + /* M1 of the #5461 review: servers enabled now only, on every day the read covers. */ + Assert.Contains("JOIN config.config_monitored_servers AS c", servers, StringComparison.Ordinal); + Assert.Contains("AND c.is_enabled", servers, StringComparison.Ordinal); + + var passes = CollectionHistoryAudit.BuildSql(Perfmon); + Assert.Contains("FROM collect.perfmon_interval_baseline AS r", passes, StringComparison.Ordinal); + Assert.Contains("COUNT(*)::bigint", passes, StringComparison.Ordinal); + Assert.Contains("AND c.is_enabled", passes, StringComparison.Ordinal); + + Assert.Throws(() => CollectionHistoryAudit.BuildSql(new Rollup("x; DROP TABLE y", true))); + Assert.Throws(() => CollectionHistoryAudit.BuildSql(new Rollup("x; DROP TABLE y", false, CollectionHistoryAudit.ReadShape.Probe))); + } + + /// #5461: the probe statement keeps the three things the big-store timing depends on, and drops the + /// hours nobody has a row in. + [Fact] + public void TheProbeSql_IsAPerServerPerHourLateralWithALimit_OnATimestampSeries_AndDropsEmptyHours() + { + var probe = CollectionHistoryAudit.BuildSql(new Rollup("collect.query_store_stats_hourly", false, CollectionHistoryAudit.ReadShape.Probe)); + Assert.DoesNotContain("COUNT(DISTINCT", probe, StringComparison.Ordinal); + Assert.Contains("generate_series($1::timestamp, $2::timestamp, INTERVAL '1 hour')", probe, StringComparison.Ordinal); + Assert.Contains("CROSS JOIN config.config_monitored_servers AS c", probe, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN LATERAL", probe, StringComparison.Ordinal); + Assert.Contains("FROM collect.query_store_stats_hourly AS r", probe, StringComparison.Ordinal); + Assert.Contains("r.server_id = c.server_id", probe, StringComparison.Ordinal); + Assert.Contains("r.bucket = h.bucket", probe, StringComparison.Ordinal); + Assert.Contains("WHERE c.is_enabled", probe, StringComparison.Ordinal); + Assert.Contains("h.bucket < $2::timestamp", probe, StringComparison.Ordinal); + + /* LIMIT 1 inside the lateral keeps the planner from flattening it to a semi join that reads the whole range. */ + Assert.Matches(@"(?s)LATERAL \(.*LIMIT 1\s*\) AS p ON true", probe); + + /* An hour with no server is ABSENT, as in the scan: IsSettled counts any bucket, so a zero row would mark a + hole as materialized. */ + Assert.Contains("HAVING COUNT(p.one) > 0", probe, StringComparison.Ordinal); + + /* The scan shape is unchanged for a Scan source. */ + var scan = CollectionHistoryAudit.BuildSql(new Rollup("collect.query_stats_interval_hourly", false)); + Assert.DoesNotContain("LATERAL", scan, StringComparison.Ordinal); + } + + /// #5461: the interval hourly is not audited directly, and the reason is pinned. Each assertion's + /// message names the reason, so a change to one of these CREATE statements says why the audit needs a look. + [Fact] + public void TheIntervalHourlyIsNotAudited_BecauseItsChildAndSiblingReadTheSameRawTableWithNoFilter() + { + var corrected = TimescaleSupport.CreateQueryStoreStatsCorrectedHourlySql; + var hourly = TimescaleSupport.CreateQueryStoreStatsHourlySql; + var interval = TimescaleSupport.CreateQueryStoreStatsIntervalHourlySql; + const string Why = " (#5461: the history audit does not read query_store_stats_interval_hourly because its same-width child" + + " query_store_stats_corrected_hourly has no filter on it, and query_store_stats_hourly reads the same raw table with no filter," + + " so a server missing from the interval hourly is missing from both audited views. Re-decide the audit's coverage.)"; + + Assert.True(corrected.Contains("FROM collect.query_store_stats_interval_hourly", StringComparison.Ordinal), + "the corrected hourly no longer reads FROM collect.query_store_stats_interval_hourly" + Why); + Assert.True(corrected.Contains("time_bucket('1 hour', bucket)", StringComparison.Ordinal), + "the corrected hourly is no longer a time_bucket('1 hour', bucket) over the interval hourly" + Why); + Assert.False(corrected.Contains("WHERE", StringComparison.OrdinalIgnoreCase), + "the corrected hourly now has a WHERE on the interval hourly" + Why); + + foreach (var (name, sql) in new[] { ("query_store_stats_hourly", hourly), ("query_store_stats_interval_hourly", interval) }) + { + Assert.True(System.Text.RegularExpressions.Regex.IsMatch(sql, @"FROM collect\.query_store_stats\s"), + name + " no longer reads FROM collect.query_store_stats" + Why); + Assert.False(sql.Contains("WHERE", StringComparison.OrdinalIgnoreCase), + name + " now has a WHERE on collect.query_store_stats" + Why); + } + + Assert.Equal(new[] { TimescaleSupport.QueryStoreStatsIntervalHourlyView }, CollectionHistoryAudit.UnauditedHourlyViews.ToArray()); + } + + /// #5461: the two probe sources each keep a (server_id, bucket) index, every hourly aggregate is + /// audited with a shape or is the one documented exclusion, and no other source is probed. + [Fact] + public void EveryProbeSourceKeepsAServerIdBucketIndex_AndEveryHourlyAggregateIsAuditedOrTheOneExclusion() + { + foreach (var rollup in CollectionHistoryAudit.Rollups.Where(r => r.Shape == CollectionHistoryAudit.ReadShape.Probe)) + { + var view = rollup.Relation["collect.".Length..]; + Assert.True(TimescaleSupport.QueryStoreRollupKeptIndexesSql.TryGetValue(view, out var kept), + view + " is probed but TimescaleSupport keeps no indexes for it"); + Assert.Contains("a1.attname = 'server_id'", kept, StringComparison.Ordinal); + Assert.Contains("a2.attname = 'bucket'", kept, StringComparison.Ordinal); + Assert.False(rollup.JudgesPasses, "a probe only learns whether a server has a row in an hour, never passes"); + } + + Assert.Equal( + new[] { "collect.query_store_stats_corrected_hourly", "collect.query_store_stats_hourly" }, + CollectionHistoryAudit.Rollups.Where(r => r.Shape == CollectionHistoryAudit.ReadShape.Probe).Select(r => r.Relation).OrderBy(x => x, StringComparer.Ordinal)); + + var audited = CollectionHistoryAudit.Rollups.Select(r => r.Relation).ToHashSet(StringComparer.Ordinal); + foreach (var (_, view) in TimescaleSupport.HourlyAggregates) + { + var isAudited = audited.Contains("collect." + view); + var isExcluded = CollectionHistoryAudit.UnauditedHourlyViews.Contains(view); + Assert.True(isAudited ^ isExcluded, view + " must be audited with a read shape, or be the one documented exclusion, not both or neither"); + } + } + + [Fact] + public void TheAuditedSources_AreTheLiveHourlyAggregatesForServers_AndThePerfmonBaselineForPasses() + { + var names = CollectionHistoryAudit.Rollups.Select(r => r.Relation).ToArray(); + Assert.Equal( + TimescaleSupport.HourlyAggregates.Where(a => a.View != TimescaleSupport.QueryStoreStatsIntervalHourlyView) + .Select(a => "collect." + a.View) + .Append("collect." + TimescaleSupport.PerfmonIntervalBaselineView), + names); + Assert.DoesNotContain("collect." + TimescaleSupport.QueryStoreStatsIntervalHourlyView, names); + Assert.DoesNotContain("collect." + TimescaleSupport.QueryStatsHourlyView, names); + Assert.DoesNotContain("collect." + TimescaleSupport.ProcedureStatsHourlyView, names); + + /* Only the perfmon baseline is judged on passes; the six workload rollups never are (H2). */ + Assert.Equal(new[] { "collect." + TimescaleSupport.PerfmonIntervalBaselineView }, + CollectionHistoryAudit.Rollups.Where(r => r.JudgesPasses).Select(r => r.Relation)); + + /* Each one the audit reads has the columns the statement names. */ + foreach (var (createSql, view) in TimescaleSupport.HourlyAggregates) + { + Assert.Contains("AS bucket", createSql, StringComparison.Ordinal); + Assert.Contains("server_id", createSql, StringComparison.Ordinal); + if (view != TimescaleSupport.QueryStoreStatsIntervalHourlyView) + { + Assert.Contains("collect." + view, names); + } + } + + Assert.Contains("AS bucket", TimescaleSupport.CreatePerfmonIntervalBaselineSql, StringComparison.Ordinal); + Assert.Contains("collection_time", TimescaleSupport.CreatePerfmonIntervalBaselineSql, StringComparison.Ordinal); + } + + [Fact] + public void SourcesWithIdenticalFlaggedRanges_ShareOneLine_AndUnreadSourcesAreNamed() + { + var shared = new[] { new CollectionHistoryAudit.FlaggedRange(18, 21, 0, 40, 0, 0) }; + var other = new[] { new CollectionHistoryAudit.FlaggedRange(4, 5, 10, 40, 0, 0) }; + var a = new Rollup("collect.query_store_stats_hourly", false); + var b = new Rollup("collect.query_store_stats_corrected_hourly", false); + var c = new Rollup("collect.query_stats_interval_hourly", false); + var text = CollectionHistoryAudit.Render(AuditDay, new (Rollup, IReadOnlyList)[] + { + (a, shared), (c, other), (b, shared), + }, new[] { new Rollup("collect.procedure_stats_interval_hourly", false) }); + + Assert.Contains("query_store_stats_hourly, query_store_stats_corrected_hourly: 18:00-21:00Z (servers as low as 0 vs usual 40)", text, StringComparison.Ordinal); + Assert.Contains("query_stats_interval_hourly: 04:00-05:00Z (servers as low as 10 vs usual 40)", text, StringComparison.Ordinal); + Assert.Equal(1, text.Split("18:00-21:00Z").Length - 1); + Assert.Contains("Not read for this day: procedure_stats_interval_hourly", text, StringComparison.Ordinal); + Assert.DoesNotContain("Not read", CollectionHistoryAudit.Render(AuditDay, new (Rollup, IReadOnlyList)[] { (a, shared) }), StringComparison.Ordinal); + } + + [Fact] + public void TheMetric_IsSelfMonitorFamily_Warning_AndNumeric() + { + Assert.Equal("Collection Gaps In History", DarlingSelfAlertEvaluator.CollectionGapsInHistoryMetric); + Assert.Equal(AlertFamily.SelfMonitor, AlertFamily.Of(DarlingSelfAlertEvaluator.CollectionGapsInHistoryMetric)); + Assert.Equal("WARNING", AlertSeverity.ForMetric(DarlingSelfAlertEvaluator.CollectionGapsInHistoryMetric).BadgeText); + Assert.False(PerformanceMonitor.Common.AlertMetricClassifier.IsStateOnly(DarlingSelfAlertEvaluator.CollectionGapsInHistoryMetric)); + + /* The stamp key is its own row, distinct from the three daily documents'. */ + var others = new[] + { + PgSelfAlertDeliveryStampStore.CostDigestStateKey, PgSelfAlertDeliveryStampStore.FleetSweepRollupStateKey, + PgSelfAlertDeliveryStampStore.AnalysisSinglesDigestStateKey, + }; + Assert.DoesNotContain(PgSelfAlertDeliveryStampStore.HistoryAuditStateKey, others); + } + + /* ---------------- the evaluator: once a day, one alert, failure isolation ---------------- */ + + private sealed class MemoryStampStore : ISelfAlertDeliveryStampStore + { + public Dictionary Stamps { get; } = new(StringComparer.Ordinal); + + public Task GetDeliveredAtUtcAsync(string stateKey, CancellationToken cancellationToken) => + Task.FromResult(Stamps.TryGetValue(stateKey, out var at) ? at : (DateTime?)null); + + public Task RecordDeliveredAtUtcAsync(string stateKey, DateTime deliveredAtUtc, CancellationToken cancellationToken) + { + Stamps[stateKey] = deliveredAtUtc; + return Task.CompletedTask; + } + } + + private sealed class NoHistoryStore : IAlertHistoryStore + { + public Task RecordAlertAsync(AlertHistoryRecord record) => Task.CompletedTask; + + public Task GetLastEmailSentUtcAsync(string serverId, string metricName, string? dedupKey = null) => + Task.FromResult(null); + + public Task GetLastWebhookSentUtcAsync(string serverId, string metricName, string? dedupKey = null) => + Task.FromResult(null); + + public Task GetLastAlertTimeAsync(string serverId, string metricName, string? dedupKey = null) => + Task.FromResult(null); + + public Task GetLastDeliveredPageUtcAsync(string serverId, string metricName) => Task.FromResult(null); + } + + /// One "process": its own evaluator, deliverer and clock over a stamp store the test shares. + private sealed class Process + { + public DarlingSelfAlertTests.RecordingDeliverer Deliverer { get; } = new(); + public DarlingSelfAlertTests.FakeSettings Settings { get; } = new(); + public DateTime Now { get; set; } + public AlertReadFailureCounter ReadFailures { get; } = new(); + public DarlingSelfAlertTests.CapturingLogger Log { get; } = new(); + public DarlingSelfAlertEvaluator Evaluator { get; } + + public Process(MemoryStampStore stamps, DateTime now) + { + Now = now; + Evaluator = new DarlingSelfAlertEvaluator( + Settings, Deliverer, new NoHistoryStore(), _ => false, + logger: Log, utcNow: () => Now, readFailures: ReadFailures, deliveryStamps: stamps); + } + + public int Reads { get; private set; } + + public Task RunAsync(IReadOnlyList queryStats, IReadOnlyList? perfmon = null) => + Evaluator.ApplyCollectionHistoryAuditAsync( + (rollup, from, to, token) => + { + Reads++; + Assert.Equal(AuditDay.AddDays(-7), from); + /* The read runs up to the current hour (every tick of these tests is on the day after the audited day + or later), so a bucket after a hole across midnight is in it. */ + Assert.Equal(Now.Date.AddHours(Now.Hour), to); + return Task.FromResult?>( + rollup.Relation == QueryStats.Relation ? queryStats : perfmon); + }, + new[] { QueryStats, Perfmon }, Ct); + + /// A run whose reader is the test's own: per-relation behaviour, and a count of reads of each. + public Task RunWithAsync(Func?> reader, Dictionary? reads = null) => + Evaluator.ApplyCollectionHistoryAuditAsync( + (rollup, from, to, token) => + { + if (reads is not null) + { + reads[rollup.Relation] = (reads.TryGetValue(rollup.Relation, out var seen) ? seen : 0) + 1; + } + + return Task.FromResult(reader(rollup, from)); + }, + new[] { QueryStats, Perfmon }, Ct); + } + + private static readonly DateTime FirstPass = new(2026, 10, 11, 3, 5, 0, DateTimeKind.Utc); + + [Fact] + public async Task OneAlertPerRun_NamingTheDayAndEachFlaggedRollup() + { + var p = new Process(new MemoryStampStore(), FirstPass.AddHours(1)); + var holey = WithAuditDay(Week(), h => h is >= 18 and <= 20 ? null : Usual(h)); + var thin = WithAuditDay(Week(), h => h is >= 5 and <= 7 ? (40, Usual(h).Passes / 5) : Usual(h)); + + await p.RunAsync(holey, thin); + + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.Equal("Collection Gaps In History", fired.MetricName); + Assert.Equal(AlertSeverityLevel.Warning, fired.Severity); + Assert.Equal("collectionhistoryaudit:2026-10-10", fired.ServerKey); + Assert.Contains("2026-10-10", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("query_stats_interval_hourly: 18:00-21:00Z (servers as low as 0 vs usual 40)", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("perfmon_interval_baseline: 05:00-08:00Z (servers as low as 40 vs usual 40, collection passes as low as 300 vs usual 1,500)", fired.DetailText, StringComparison.Ordinal); + Assert.DoesNotContain("Not read", fired.DetailText, StringComparison.Ordinal); + Assert.Equal(6, fired.NumericCurrentValue); + } + + [Fact] + public async Task ANormalDay_RaisesNothing_AndStillSpendsTheDay() + { + var stamps = new MemoryStampStore(); + var p = new Process(stamps, FirstPass.AddHours(1)); + var normal = WithAuditDay(Week(), h => Usual(h)); + + await p.RunAsync(normal, normal); + Assert.Empty(p.Deliverer.Outcomes); + Assert.Equal(2, p.Reads); + + /* An hour later the day is spent: no second read of either source. */ + p.Now = p.Now.AddHours(1); + await p.RunAsync(normal, normal); + Assert.Equal(2, p.Reads); + } + + [Fact] + public async Task ARestartOnTheSameDay_DoesNotRaiseItTwice_AndTheNextDayDoes() + { + var stamps = new MemoryStampStore(); + var holey = WithAuditDay(Week(), h => h is >= 18 and <= 20 ? null : Usual(h)); + + var first = new Process(stamps, FirstPass); + await first.RunAsync(holey, holey); + Assert.Single(first.Deliverer.Outcomes); + Assert.Equal(new DateTime(2026, 10, 11, 3, 0, 0, DateTimeKind.Utc), stamps.Stamps["history_audit_slot"]); + + /* A fresh process over the same store, hours later the same UTC day. */ + var second = new Process(stamps, FirstPass.AddHours(10)); + await second.RunAsync(holey, holey); + Assert.Empty(second.Deliverer.Outcomes); + Assert.Equal(0, second.Reads); + + /* The next UTC day, at 03:00Z or after, is due again (and audits the day before it). */ + var third = new Process(stamps, new DateTime(2026, 10, 12, 3, 0, 0, DateTimeKind.Utc)); + Assert.True(await DueAsync(third, holey)); + } + + private static async Task DueAsync(Process p, List buckets) + { + var read = false; + await p.Evaluator.ApplyCollectionHistoryAuditAsync( + (rollup, from, to, token) => + { + read = true; + return Task.FromResult?>(buckets); + }, + new[] { QueryStats }, Ct); + return read; + } + + [Fact] + public async Task TheSlotIsThreeOClock_NothingIsReadBeforeIt_OrWithAlertsOff() + { + /* H1 of the #5461 review: the audit slot is 03:00Z, after the heaviest rollup (01:15) and the corrected + rollup (02:0X) have both materialized the audited day's last hour. */ + var early = new Process(new MemoryStampStore(), new DateTime(2026, 10, 11, 2, 59, 0, DateTimeKind.Utc)); + Assert.False(await DueAsync(early, Week())); + early.Now = new DateTime(2026, 10, 11, 3, 0, 0, DateTimeKind.Utc); + Assert.True(await DueAsync(early, Week())); + + var off = new Process(new MemoryStampStore(), FirstPass); + off.Settings.AlertsEnabled = false; + Assert.False(await DueAsync(off, Week())); + } + + [Theory] + [InlineData(3, 0)] + [InlineData(3, 59)] + [InlineData(14, 20)] + [InlineData(23, 59)] + public async Task AHoleInTheLastHour_IsJudged_AtEveryTick_OnceTheBucketAfterItExists(int hour, int minute) + { + /* L1 of the #5461 review: a hole at 23:00 with a bucket after it, whenever the tick falls from 03:00Z on. The + 01:30Z rule it replaced did judge hour 23 at every one of these ticks, so this guards the 03:00Z slot and + the always-judged hour together, not that rule. The midnight test below is the one that was red before. */ + var p = new Process(new MemoryStampStore(), new DateTime(2026, 10, 11, hour, minute, 0, DateTimeKind.Utc)); + var holeAt23 = WithAuditDay(Week(), h => h == 23 ? null : Usual(h)); + holeAt23.Add(new HourBucket(AuditDay.AddDays(1), 40, Usual(0).Passes)); + + await p.RunAsync(holeAt23, holeAt23); + + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.Contains("query_stats_interval_hourly: 23:00-24:00Z", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("23:00-24:00Z", fired.DetailText, StringComparison.Ordinal); + } + + [Fact] + public async Task ASourceThatIsNotReady_IsRetriedOnLaterTicks_AndNothingIsRaisedOrStampedUntilItIs() + { + var stamps = new MemoryStampStore(); + var p = new Process(stamps, FirstPass); + var through22 = WithAuditDay(Week(), h => h <= 22 ? Usual(h) : null); + var settled = WithAuditDay(Week(), h => h is >= 18 and <= 20 ? null : Usual(h)); + var reads = new Dictionary(); + + /* Perfmon is ready and holey; the workload rollup has not materialized hour 23 yet. */ + await p.RunWithAsync((r, from) => r.Relation == QueryStats.Relation ? through22 : settled, reads); + Assert.Empty(p.Deliverer.Outcomes); + Assert.Empty(stamps.Stamps); + + /* A later tick re-reads ONLY the unready source; it is ready now, so the one alert goes out, naming no + unread source, and the day is stamped. */ + p.Now = FirstPass.AddHours(1); + await p.RunWithAsync((r, from) => settled, reads); + Assert.Equal(2, reads[QueryStats.Relation]); + Assert.Equal(1, reads[Perfmon.Relation]); + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.DoesNotContain("Not read", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("perfmon_interval_baseline: 18:00-21:00Z", fired.DetailText, StringComparison.Ordinal); + Assert.Equal(new DateTime(2026, 10, 11, 3, 0, 0, DateTimeKind.Utc), stamps.Stamps["history_audit_slot"]); + } + + [Fact] + public async Task ASourceThatNeverBecomesReadable_IsGivenUpOnAtTheNextSlot_WithOneWarning_AndTheAlertNamesIt() + { + var stamps = new MemoryStampStore(); + var p = new Process(stamps, FirstPass); + var holey = WithAuditDay(Week(), h => h is >= 18 and <= 20 ? null : Usual(h)); + var day10 = AuditDay.AddDays(-7); + + /* The workload rollup times out on every tick; perfmon reads. Held, not raised, until the slot after. */ + IReadOnlyList? Reader(Rollup r, DateTime from) + { + if (from != day10 || r.Relation == QueryStats.Relation) + { + throw new TimeoutException("statement timeout"); + } + + return holey; + } + + foreach (var tick in new[] { FirstPass, FirstPass.AddHours(8), FirstPass.AddHours(22), new DateTime(2026, 10, 12, 2, 59, 0, DateTimeKind.Utc) }) + { + p.Now = tick; + await p.RunWithAsync(Reader); + Assert.Empty(p.Deliverer.Outcomes); + Assert.Empty(stamps.Stamps); + } + + /* 03:00Z the next day: give up on the unread source, one warning naming it, and raise what was read. */ + p.Now = new DateTime(2026, 10, 12, 3, 0, 0, DateTimeKind.Utc); + await p.RunWithAsync(Reader); + + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.Contains("2026-10-10", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("perfmon_interval_baseline: 18:00-21:00Z", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("Not read for this day: query_stats_interval_hourly", fired.DetailText, StringComparison.Ordinal); + Assert.Single(p.Log.Entries, e => e.Message.Contains("gave up on collect.query_stats_interval_hourly", StringComparison.Ordinal)); + } + + /// A run whose reader serves each source from its own list, cut at the bounds the evaluator passes, as the + /// real read is: a bucket outside [from, to) is not seen. + private static Task RunBoundedAsync(Process p, Func?> source, params Rollup[] rollups) => + p.Evaluator.ApplyCollectionHistoryAuditAsync( + (rollup, from, to, token) => + { + var all = source(rollup); + return Task.FromResult?>( + all?.Where(b => b.HourUtc >= from && b.HourUtc < to).ToList()); + }, + rollups.Length == 0 ? new[] { QueryStats, Perfmon } : rollups, Ct); + + [Fact] + public async Task AnOutageFrom2230To0130_IsRaisedOnceCollectionResumes_NotAfterTheGiveUp() + { + /* H1 of the #5461 round 2 review. No row in any source from 23:00Z to 01:00Z; rows again at 01:00Z. With the + read fixed at 01:00Z no bucket after the hole was in it, so every source read "not ready" forever and the + day, hole and any unrelated thin hour included, was never judged. */ + var stamps = new MemoryStampStore(); + var p = new Process(stamps, FirstPass); + var days = WithAuditDay(Week(), h => h == 23 ? null : Usual(h)); + days.Add(new HourBucket(AuditDay.AddDays(1).AddHours(1), 40, Usual(1).Passes)); + days.Add(new HourBucket(AuditDay.AddDays(1).AddHours(2), 40, Usual(2).Passes)); + + await RunBoundedAsync(p, _ => days); + + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.Contains("23:00-24:00Z", fired.DetailText, StringComparison.Ordinal); + Assert.DoesNotContain("Not read", fired.DetailText, StringComparison.Ordinal); + Assert.DoesNotContain("not complete", fired.DetailText, StringComparison.Ordinal); + Assert.Equal(1, fired.NumericCurrentValue); + Assert.Equal(new DateTime(2026, 10, 11, 3, 0, 0, DateTimeKind.Utc), stamps.Stamps["history_audit_slot"]); + } + + [Fact] + public async Task ASourceThatNeverBecomesReady_KeepsItsFlaggedHours_AndTheGiveUpAlertMarksThemNotComplete() + { + /* H1 of the #5461 round 2 review: a rollup whose refresh stopped at 10:00Z. Perfmon is clean. Every read finds + the rollup not ready; its hole (hour 10 to 24, zero servers) must reach the alert at the give-up. */ + var stamps = new MemoryStampStore(); + var p = new Process(stamps, FirstPass); + var stopped = WithAuditDay(Week(), h => h <= 9 ? Usual(h) : null); + var clean = WithAuditDay(Week(), h => Usual(h)); + IReadOnlyList? Source(Rollup r) => r.Relation == QueryStats.Relation ? stopped : clean; + + foreach (var tick in new[] { FirstPass, FirstPass.AddHours(8), new DateTime(2026, 10, 12, 2, 59, 0, DateTimeKind.Utc) }) + { + p.Now = tick; + await RunBoundedAsync(p, Source); + Assert.Empty(p.Deliverer.Outcomes); + Assert.Empty(stamps.Stamps); + } + + p.Now = new DateTime(2026, 10, 12, 3, 0, 0, DateTimeKind.Utc); + await RunBoundedAsync(p, Source); + + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.Contains("query_stats_interval_hourly: 10:00-24:00Z (servers as low as 0 vs usual 40) [not complete", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("Not read for this day: query_stats_interval_hourly", fired.DetailText, StringComparison.Ordinal); + Assert.Equal(14, fired.NumericCurrentValue); + Assert.Single(p.Log.Entries, e => e.Message.Contains("gave up on collect.query_stats_interval_hourly", StringComparison.Ordinal)); + } + + [Fact] + public async Task AGiveUpWithNoFlaggedHours_RaisesNothing_AndLogsTheWarning() + { + var p = new Process(new MemoryStampStore(), FirstPass); + var clean = WithAuditDay(Week(), h => Usual(h)); + + p.Now = FirstPass; + await p.RunWithAsync((r, from) => r.Relation == QueryStats.Relation ? throw new TimeoutException("statement timeout") : clean); + p.Now = new DateTime(2026, 10, 12, 3, 0, 0, DateTimeKind.Utc); + await p.RunWithAsync((r, from) => r.Relation == QueryStats.Relation && from == AuditDay.AddDays(-7) ? throw new TimeoutException("statement timeout") : clean); + + Assert.Empty(p.Deliverer.Outcomes); + Assert.Single(p.Log.Entries, e => e.Message.Contains("gave up on collect.query_stats_interval_hourly", StringComparison.Ordinal)); + } + + /// A logger that switches the master switch off when the give-up warning is written: the switch going off + /// between the apply's top check and the give-up's own send. + private sealed class SwitchOffOnGiveUpLogger : Microsoft.Extensions.Logging.ILogger + { + private readonly DarlingSelfAlertTests.FakeSettings _settings; + public List Messages { get; } = new(); + + public SwitchOffOnGiveUpLogger(DarlingSelfAlertTests.FakeSettings settings) => _settings = settings; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + + public void Log( + Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, + TState state, Exception? exception, Func formatter) + { + var message = formatter(state, exception); + Messages.Add(message); + if (message.Contains("gave up on", StringComparison.Ordinal)) + { + _settings.AlertsEnabled = false; + } + } + } + + [Fact] + public async Task AGiveUpWithAlertsOff_LogsTheWarning_RaisesNothing_AndStillClearsTheHeldDay() + { + /* #3464 master-switch gate on the give-up's own send. The same never-ready source as above, so the give-up has + flagged hours to raise; the switch goes off as the give-up logs its warning. */ + var settings = new DarlingSelfAlertTests.FakeSettings(); + var deliverer = new DarlingSelfAlertTests.RecordingDeliverer(); + var log = new SwitchOffOnGiveUpLogger(settings); + var now = FirstPass; + var evaluator = new DarlingSelfAlertEvaluator( + settings, deliverer, new NoHistoryStore(), _ => false, + logger: log, utcNow: () => now, readFailures: new AlertReadFailureCounter(), deliveryStamps: new MemoryStampStore()); + var stopped = WithAuditDay(Week(), h => h <= 9 ? Usual(h) : null); + var clean = WithAuditDay(Week(), h => Usual(h)); + + Task Run() => evaluator.ApplyCollectionHistoryAuditAsync( + (rollup, from, to, token) => + { + var all = rollup.Relation == QueryStats.Relation ? stopped : clean; + return Task.FromResult?>(all.Where(b => b.HourUtc >= from && b.HourUtc < to).ToList()); + }, + new[] { QueryStats, Perfmon }, Ct); + + await Run(); + now = new DateTime(2026, 10, 12, 3, 0, 0, DateTimeKind.Utc); + await Run(); + + Assert.Empty(deliverer.Outcomes); + Assert.Single(log.Messages, m => m.Contains("gave up on collect.query_stats_interval_hourly", StringComparison.Ordinal)); + + /* Switched back on, the day is not given up on a second time: the held state was cleared. */ + settings.AlertsEnabled = true; + now = new DateTime(2026, 10, 12, 3, 30, 0, DateTimeKind.Utc); + await Run(); + + Assert.Single(log.Messages, m => m.Contains("gave up on", StringComparison.Ordinal)); + Assert.DoesNotContain(deliverer.Outcomes, o => o.DetailText?.Contains("2026-10-10", StringComparison.Ordinal) == true); + } + + /// A deliverer that applies the cooldown the way the real one does for a self-alert (no incidents): one + /// send per (server key, metric) pair, the repeat throttled. Throttled counts as delivered upstream, so a + /// throttled alert simply never reaches a channel. + private sealed class CooldownDeliverer : IAlertDeliverer + { + private readonly HashSet _sent = new(StringComparer.Ordinal); + public List Delivered { get; } = new(); + public List Throttled { get; } = new(); + + public Task DeliverAsync(AlertOutcome outcome, CancellationToken cancellationToken = default) + { + (_sent.Add(outcome.ServerKey + "|" + outcome.MetricName) ? Delivered : Throttled).Add(outcome); + return Task.CompletedTask; + } + + public async Task DeliverAndReportAsync(AlertOutcome outcome, CancellationToken cancellationToken = default) + { + await DeliverAsync(outcome, cancellationToken); + return null; + } + } + + [Fact] + public async Task TheGiveUpAlertForOneDay_AndTheNextDaysAudit_AreBothDelivered_InOneTick() + { + /* M1 of the #5461 round 2 review: at the first tick on or after D+2 03:00Z the give-up for D fires, then the + same call audits D+1. Under one delivery key the second was throttled by the cooldown and never reached a + channel; with the audit day in the key they are two incidents. */ + var cooldown = new CooldownDeliverer(); + var stamps = new MemoryStampStore(); + var settings = new DarlingSelfAlertTests.FakeSettings(); + var now = FirstPass; + var evaluator = new DarlingSelfAlertEvaluator( + settings, cooldown, new NoHistoryStore(), _ => false, utcNow: () => now, deliveryStamps: stamps); + + var dayOne = WithAuditDay(Week(), h => h is >= 18 and <= 20 ? null : Usual(h)); + var dayTwo = Week().Concat(Day(AuditDay, h => Usual(h))).Concat(Day(AuditDay.AddDays(1), h => h is >= 5 and <= 6 ? null : Usual(h))).ToList(); + var firstDayFrom = AuditDay.AddDays(-7); + + Task Run() => evaluator.ApplyCollectionHistoryAuditAsync( + (rollup, from, to, token) => + { + if (from == firstDayFrom) + { + /* Day D: the workload rollup keeps timing out; perfmon reads and is holey. */ + return rollup.Relation == QueryStats.Relation + ? throw new TimeoutException("statement timeout") + : Task.FromResult?>(dayOne); + } + + return Task.FromResult?>(dayTwo); + }, + new[] { QueryStats, Perfmon }, Ct); + + await Run(); + now = new DateTime(2026, 10, 12, 2, 59, 0, DateTimeKind.Utc); + await Run(); + Assert.Empty(cooldown.Delivered); + + now = new DateTime(2026, 10, 12, 3, 0, 0, DateTimeKind.Utc); + await Run(); + + Assert.Equal(2, cooldown.Delivered.Count); + Assert.Empty(cooldown.Throttled); + Assert.Contains("2026-10-10", cooldown.Delivered[0].DetailText, StringComparison.Ordinal); + Assert.Contains("Not read for this day: query_stats_interval_hourly", cooldown.Delivered[0].DetailText, StringComparison.Ordinal); + Assert.Contains("2026-10-11", cooldown.Delivered[1].DetailText, StringComparison.Ordinal); + Assert.NotEqual(cooldown.Delivered[0].ServerKey, cooldown.Delivered[1].ServerKey); + Assert.StartsWith("collectionhistoryaudit:2026-10-1", cooldown.Delivered[0].ServerKey, StringComparison.Ordinal); + } + + [Fact] + public async Task TheAlertText_SaysAPerfmonScheduleChangeCanTriggerItForAFewDays() + { + var p = new Process(new MemoryStampStore(), FirstPass.AddHours(1)); + var holey = WithAuditDay(Week(), h => h is >= 18 and <= 20 ? null : Usual(h)); + await p.RunAsync(holey, holey); + + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.Contains("A change to the perfmon collection schedule", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("for a few days", fired.DetailText, StringComparison.Ordinal); + Assert.Equal(3, fired.NumericCurrentValue); + } + + [Fact] + public async Task AFailedRead_IsRetriedAlone_WarnsOnEachFailure_AndTheAlertIsRaisedOnceEverySourceIsRead() + { + var p = new Process(new MemoryStampStore(), FirstPass.AddHours(1)); + var holey = WithAuditDay(Week(), h => h == 12 ? null : Usual(h)); + var reads = new Dictionary(); + + await p.RunWithAsync((r, from) => r.Relation == QueryStats.Relation + ? throw new TimeoutException("statement timeout") + : holey, reads); + Assert.Empty(p.Deliverer.Outcomes); + Assert.Single(p.Log.Entries, e => e.Message.Contains("could not read", StringComparison.Ordinal)); + Assert.Equal(1L, p.ReadFailures.ReadInstance().ReadFailures); + + p.Now = p.Now.AddHours(1); + await p.RunWithAsync((r, from) => holey, reads); + Assert.Equal(1, reads[Perfmon.Relation]); + Assert.Equal(2, reads[QueryStats.Relation]); + var fired = Assert.Single(p.Deliverer.Outcomes); + Assert.StartsWith("Retained history for 2026-10-10", fired.DetailText, StringComparison.Ordinal); + Assert.Contains("12:00-13:00Z", fired.DetailText, StringComparison.Ordinal); + Assert.DoesNotContain("Not read", fired.DetailText, StringComparison.Ordinal); + } + + [Fact] + public async Task EveryReadFailing_SpendsNothing_SoTheNextTickAsksAgain() + { + var stamps = new MemoryStampStore(); + var p = new Process(stamps, FirstPass); + await p.Evaluator.ApplyCollectionHistoryAuditAsync( + (rollup, from, to, token) => throw new TimeoutException("down"), + new[] { QueryStats, Perfmon }, Ct); + Assert.Empty(p.Deliverer.Outcomes); + Assert.Empty(stamps.Stamps); + + p.Now = FirstPass.AddHours(1); + var holey = WithAuditDay(Week(), h => h == 4 ? null : Usual(h)); + await p.RunAsync(holey, holey); + Assert.Single(p.Deliverer.Outcomes); + } + + [Fact] + public async Task AnAbsentSource_AndANewStore_AreQuiet_NotAnAlertOrAWarning() + { + var p = new Process(new MemoryStampStore(), FirstPass.AddHours(1)); + /* Perfmon is absent (null); query stats has two prior days only. */ + await p.RunAsync(WithAuditDay(Week(priorDays: 2), h => null), null); + Assert.Empty(p.Deliverer.Outcomes); + Assert.Empty(p.Log.Entries); + + /* Both done, so the day is spent. */ + var reads = p.Reads; + p.Now = p.Now.AddHours(1); + await p.RunAsync(WithAuditDay(Week(priorDays: 2), h => null), null); + Assert.Equal(reads, p.Reads); + } + + [Fact] + public async Task AFirstPassAtFourteenHundred_StillAnchorsTheNextAuditOnThreeOClock() + { + var stamps = new MemoryStampStore(); + var afternoon = new DateTime(2026, 10, 11, 14, 20, 0, DateTimeKind.Utc); + var p = new Process(stamps, afternoon); + await p.RunAsync(WithAuditDay(Week(), h => Usual(h)), null); + Assert.Equal(new DateTime(2026, 10, 11, 3, 0, 0, DateTimeKind.Utc), stamps.Stamps["history_audit_slot"]); + } + + /* ---------------- live: seeded sources, through the real read ---------------- */ + + private static string? Env() => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private static async Task ExecAsync(NpgsqlConnection connection, string sql) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(Ct); + } + + /// + /// Seeds 22 days from 2026-09-26 in the shape of the real sources. Servers 1-12 are enabled and 13-14 are + /// disabled but still have rows (M1: they must not count). audit_hourly is a workload rollup (servers + /// only: several query rows per server and hour); audit_passes is the perfmon baseline's shape (a row per + /// server per collection pass, four a hour). Days: 2026-10-10 has a 3-hour hole at 18-20Z in both; + /// 2026-10-14 has passes at 25% from 5-7Z with every server present; 2026-10-17 is weekend-shaped, one third of + /// the queries with every server and every pass present. + /// + private static async Task SeedAsync(NpgsqlConnection connection) + { + await ExecAsync(connection, @" +CREATE SCHEMA IF NOT EXISTS collect; +CREATE SCHEMA IF NOT EXISTS config; +CREATE TABLE config.config_monitored_servers (server_id integer PRIMARY KEY, is_enabled boolean NOT NULL); +INSERT INTO config.config_monitored_servers SELECT s, s <= 12 FROM generate_series(1, 14) AS s; +CREATE TABLE collect.audit_hourly (bucket timestamp NOT NULL, server_id integer NOT NULL, query_key integer NOT NULL); +CREATE TABLE collect.audit_passes (bucket timestamp NOT NULL, server_id integer NOT NULL, collection_time timestamp NOT NULL);"); + + await ExecAsync(connection, @" +INSERT INTO collect.audit_hourly (bucket, server_id, query_key) +SELECT b, s, q +FROM generate_series(TIMESTAMP '2026-09-26', TIMESTAMP '2026-10-17 23:00', INTERVAL '1 hour') AS b +CROSS JOIN generate_series(1, 14) AS s +CROSS JOIN generate_series(1, 3) AS q +WHERE NOT (b::date = DATE '2026-10-10' AND extract(hour FROM b) BETWEEN 18 AND 20) +AND NOT (b::date = DATE '2026-10-17' AND q > 1); + +INSERT INTO collect.audit_passes (bucket, server_id, collection_time) +SELECT b, s, b + p * INTERVAL '15 minutes' +FROM generate_series(TIMESTAMP '2026-09-26', TIMESTAMP '2026-10-17 23:00', INTERVAL '1 hour') AS b +CROSS JOIN generate_series(1, 14) AS s +CROSS JOIN generate_series(0, 3) AS p +WHERE NOT (b::date = DATE '2026-10-10' AND extract(hour FROM b) BETWEEN 18 AND 20) +AND NOT (b::date = DATE '2026-10-14' AND extract(hour FROM b) BETWEEN 5 AND 7 AND p > 0);"); + } + + [Fact] + public async Task Live_SeededSources_FlagTheHoleAndTheThinPasses_AndStayQuietOnANormalDayAndAWeekend() + { + Assert.SkipWhen(string.IsNullOrEmpty(Env()), "Set DARLING_TEST_PG to run the #5450 live tests."); + var ok = false; + var scratch = await ScratchPostgres.CreateAsync(Env()!, Ct); + try + { + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(Ct); + await SeedAsync(connection); + await using var source = NpgsqlDataSource.Create(scratch.ConnectionString); + var queries = new Rollup("collect.audit_hourly", false); + var passes = new Rollup("collect.audit_passes", true); + + async Task?> AuditAsync(Rollup rollup, DateTime day) + { + var buckets = await CollectionHistoryAudit.ReadAsync( + source, rollup, CollectionHistoryAudit.ReadFrom(day), CollectionHistoryAudit.ReadTo(day), Ct); + Assert.NotNull(buckets); + Assert.True(CollectionHistoryAudit.IsSettled(day, buckets!)); + return CollectionHistoryAudit.Analyze(day, buckets!); + } + + DateTime Utc(int month, int day) => new(2026, month, day, 0, 0, 0, DateTimeKind.Utc); + + /* The hole, in both sources. Usual counts only the 12 enabled servers, not the 14 with rows (M1), and + usual passes are 12 servers x 4 passes. */ + var hole = Assert.Single((await AuditAsync(queries, Utc(10, 10)))!); + Assert.Equal("18:00-21:00Z", CollectionHistoryAudit.RangeLabel(hole)); + Assert.Equal(12, hole.UsualServers); + Assert.Equal(0, hole.LowestServers); + var passHole = Assert.Single((await AuditAsync(passes, Utc(10, 10)))!); + Assert.Equal("18:00-21:00Z", CollectionHistoryAudit.RangeLabel(passHole)); + Assert.Equal(12, passHole.UsualServers); + Assert.Equal(48, passHole.UsualPasses); + + /* A normal day on both sides is quiet, in both sources. */ + foreach (var day in new[] { Utc(10, 9), Utc(10, 16) }) + { + Assert.Empty((await AuditAsync(queries, day))!); + Assert.Empty((await AuditAsync(passes, day))!); + } + + /* Case 2: every server present, passes at a quarter. The workload rollup (servers only) cannot see it; + the pass count can. */ + var thin = Assert.Single((await AuditAsync(passes, Utc(10, 14)))!); + Assert.Equal("05:00-08:00Z", CollectionHistoryAudit.RangeLabel(thin)); + Assert.Equal(12, thin.LowestServers); + Assert.Equal(12, thin.LowestPasses); + Assert.Equal(48, thin.UsualPasses); + Assert.Empty((await AuditAsync(queries, Utc(10, 14)))!); + + /* H2: a weekend-shaped day, a third of the queries but every server and every pass, flags nothing. */ + Assert.Empty((await AuditAsync(queries, Utc(10, 17)))!); + Assert.Empty((await AuditAsync(passes, Utc(10, 17)))!); + + /* M1: a server disabled now is out of both the audited day and usual. Disabling 6 of 12 servers flags + nothing, where counting them on prior days only would have flagged every hour. */ + await ExecAsync(connection, "UPDATE config.config_monitored_servers SET is_enabled = false WHERE server_id BETWEEN 7 AND 12;"); + Assert.Empty((await AuditAsync(queries, Utc(10, 16)))!); + Assert.Empty((await AuditAsync(passes, Utc(10, 16)))!); + await ExecAsync(connection, "UPDATE config.config_monitored_servers SET is_enabled = true WHERE server_id BETWEEN 7 AND 12;"); + + /* The same sources through the evaluator and the real read: one alert for the thin day, none for a + restart, none for the weekend-shaped day. */ + async Task RunAsync(Process process) => await process.Evaluator.ApplyCollectionHistoryAuditAsync( + (r, from, to, token) => CollectionHistoryAudit.ReadAsync(source, r, from, to, token), + new[] { queries, passes, new Rollup("collect.history_audit_absent_hourly", false) }, Ct); + + var stamps = new MemoryStampStore(); + var run = new Process(stamps, new DateTime(2026, 10, 15, 3, 0, 0, DateTimeKind.Utc)); + await RunAsync(run); + var fired = Assert.Single(run.Deliverer.Outcomes); + Assert.Contains("audit_passes: 05:00-08:00Z", fired.DetailText, StringComparison.Ordinal); + Assert.DoesNotContain("audit_hourly", fired.DetailText, StringComparison.Ordinal); + Assert.DoesNotContain("absent", fired.DetailText, StringComparison.Ordinal); + + var restarted = new Process(stamps, new DateTime(2026, 10, 15, 9, 0, 0, DateTimeKind.Utc)); + await RunAsync(restarted); + Assert.Empty(restarted.Deliverer.Outcomes); + + var weekend = new Process(new MemoryStampStore(), new DateTime(2026, 10, 18, 3, 0, 0, DateTimeKind.Utc)); + await RunAsync(weekend); + Assert.Empty(weekend.Deliverer.Outcomes); + + ok = true; + } + finally + { + await LiveStoreCleanup.RunOwnedAsync(ok, async () => await scratch.DisposeAsync()); + } + } + + [Fact] + public async Task Live_TheRealSourceList_ReadsNullOnAPlainStore_AndIsReadableOnTheAggregates() + { + Assert.SkipWhen(string.IsNullOrEmpty(Env()), "Set DARLING_TEST_PG to run the #5450 live tests."); + var ok = false; + var scratch = await ScratchPostgres.CreateAsync(Env()!, Ct); + try + { + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(Ct); + await PgMigrations.MigrateAsync(connection, Ct); + await using var source = NpgsqlDataSource.Create(scratch.ConnectionString); + + /* A plain store has none of the aggregates: every real source reads null, and the audit does nothing. */ + foreach (var rollup in CollectionHistoryAudit.Rollups) + { + Assert.Null(await CollectionHistoryAudit.ReadAsync( + source, rollup, CollectionHistoryAudit.ReadFrom(AuditDay), CollectionHistoryAudit.ReadTo(AuditDay), Ct)); + } + + /* The TimescaleDB shape: build the real aggregates (empty), and the statement is valid against each. */ + Assert.True(await TimescaleSupport.TryEnableAsync(connection, null, Ct), "TimescaleDB must be enabled on the test cluster"); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, Ct); + foreach (var (createSql, _) in TimescaleSupport.HourlyAggregates) + { + await ExecAsync(connection, createSql); + } + + await ExecAsync(connection, TimescaleSupport.CreatePerfmonIntervalBaselineSql); + + foreach (var rollup in CollectionHistoryAudit.Rollups) + { + var buckets = await CollectionHistoryAudit.ReadAsync( + source, rollup, CollectionHistoryAudit.ReadFrom(AuditDay), CollectionHistoryAudit.ReadTo(AuditDay), Ct); + Assert.NotNull(buckets); + Assert.Empty(buckets!); + } + + ok = true; + } + finally + { + await LiveStoreCleanup.RunOwnedAsync(ok, async () => await scratch.DisposeAsync()); + } + } + + /* ---------------- live: the #5461 probe read against the real Query Store rollups ---------------- */ + + private static readonly DateTime ProbeFrom = new(2026, 3, 4, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Builds the real store, strips the refresh policies (a background run must not race the manual refreshes), + /// and seeds collect.query_store_stats from 2026-03-04 00:00Z. Servers 101-103 are enabled, 104 is + /// disabled. Hours: 0 = all four servers (3 enabled), 1 = server 101 with 50 rows, 2 = nobody, 3 = servers 102 + /// and 103, 4 = only the disabled 104, 5 = all four, 6 = server 101 (outside the window the tests read). Then + /// the three Query Store hourlies are materialized, in dependency order. + /// + private static async Task SeedProbeStoreAsync(NpgsqlConnection connection) + { + await PgMigrations.MigrateAsync(connection, Ct); + Assert.True(await TimescaleSupport.TryEnableAsync(connection, null, Ct), "TimescaleDB must be enabled on the test cluster"); + await TimescaleSupport.ConvertToHypertablesAsync(connection, null, Ct); + await TimescaleSupport.EnsureContinuousAggregatesAsync(connection, null, Ct); + foreach (var (view, _, _, _, _) in TimescaleSupport.RollupViews) + { + await ExecAsync(connection, $"SELECT remove_continuous_aggregate_policy('collect.{view}', if_exists => true)"); + } + + await ExecAsync(connection, @" +INSERT INTO config.config_monitored_servers (server_id, name, host, is_enabled) VALUES + (101, 'probe-101', 'h', true), (102, 'probe-102', 'h', true), (103, 'probe-103', 'h', true), (104, 'probe-104', 'h', false); + +CREATE TEMP TABLE probe_spec (server_id integer, hr integer, n integer); +INSERT INTO probe_spec VALUES + (101, 0, 1), (102, 0, 1), (103, 0, 1), (104, 0, 1), + (101, 1, 50), + (102, 3, 1), (103, 3, 2), + (104, 4, 3), + (101, 5, 1), (102, 5, 1), (103, 5, 1), (104, 5, 1), + (101, 6, 1); + +INSERT INTO collect.query_store_stats (collection_id, collection_time, server_id, server_name, database_name, query_id, plan_id, + execution_type_desc, first_execution_time, module_name, query_hash, execution_count, avg_duration_us, avg_cpu_time_us, + max_duration_us, max_cpu_time_us, replica_role, runtime_stats_interval_id, interval_start_time_utc) +SELECT 1, TIMESTAMP '2026-03-04 00:00' + (s.hr * interval '1 hour') + ((i % 6) * interval '10 minutes'), + s.server_id, 'probe-' || s.server_id, 'db', 100 + i, 1000 + i, + 'Regular', TIMESTAMP '2026-03-04 00:00', 'mod', md5('q' || i), 10, 500, 300, 900, 700, 'PRIMARY', 77 + i, + TIMESTAMP '2026-03-04 00:00' +FROM probe_spec AS s CROSS JOIN LATERAL generate_series(1, s.n) AS i;"); + + var to = ProbeFrom.AddHours(12); + foreach (var view in new[] + { + TimescaleSupport.QueryStoreStatsHourlyView, + TimescaleSupport.QueryStoreStatsIntervalHourlyView, + TimescaleSupport.QueryStoreStatsCorrectedHourlyView, + }) + { + for (var attempt = 1; ; attempt++) + { + try + { + await using var refresh = new NpgsqlCommand( + $"CALL refresh_continuous_aggregate('collect.{view}', $1::timestamp, $2::timestamp)", connection); + refresh.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Timestamp, Value = DateTime.SpecifyKind(ProbeFrom, DateTimeKind.Unspecified) }); + refresh.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Timestamp, Value = DateTime.SpecifyKind(to, DateTimeKind.Unspecified) }); + await refresh.ExecuteNonQueryAsync(Ct); + break; + } + catch (PostgresException ex) when (ex.SqlState == "55P03" && attempt < 12) + { + await Task.Delay(TimeSpan.FromSeconds(1), Ct); + } + } + } + } + + [Fact] + public async Task Live_TheProbeRead_EqualsAnOracleDistinctServerCount_OnBothQueryStoreRollups_AndLeavesAnEmptyHourAbsent() + { + Assert.SkipWhen(string.IsNullOrEmpty(Env()), "Set DARLING_TEST_PG to run the #5461 live tests."); + var ok = false; + var scratch = await ScratchPostgres.CreateAsync(Env()!, Ct); + try + { + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(Ct); + await SeedProbeStoreAsync(connection); + await using var source = NpgsqlDataSource.Create(scratch.ConnectionString); + var to = ProbeFrom.AddHours(6); + + foreach (var view in new[] { TimescaleSupport.QueryStoreStatsHourlyView, TimescaleSupport.QueryStoreStatsCorrectedHourlyView }) + { + var probe = await CollectionHistoryAudit.ReadAsync( + source, new Rollup("collect." + view, false, CollectionHistoryAudit.ReadShape.Probe), ProbeFrom, to, Ct); + var scan = await CollectionHistoryAudit.ReadAsync( + source, new Rollup("collect." + view, false, CollectionHistoryAudit.ReadShape.Scan), ProbeFrom, to, Ct); + Assert.NotNull(probe); + Assert.NotNull(scan); + + /* The oracle, written here: distinct enabled servers per bucket, buckets with no row absent. */ + var oracle = new Dictionary(); + await using (var command = new NpgsqlCommand($@" +SELECT r.bucket, COUNT(DISTINCT r.server_id)::int +FROM collect.{view} AS r +WHERE r.server_id IN (SELECT server_id FROM config.config_monitored_servers WHERE is_enabled) +AND r.bucket >= $1 AND r.bucket < $2 +GROUP BY r.bucket", connection)) + { + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Timestamp, Value = DateTime.SpecifyKind(ProbeFrom, DateTimeKind.Unspecified) }); + command.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Timestamp, Value = DateTime.SpecifyKind(to, DateTimeKind.Unspecified) }); + await using var reader = await command.ExecuteReaderAsync(Ct); + while (await reader.ReadAsync(Ct)) + { + oracle[DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc)] = reader.GetInt32(1); + } + } + + /* Hours 0 (3 enabled of 4), 1 (one server, 50 rows), 3 (two), 5 (3 enabled of 4). Hour 2 has no + server and hour 4 only the disabled one: both ABSENT, as in the scan. Hour 6 is the exclusive end. */ + var expected = new Dictionary + { + [ProbeFrom] = 3, [ProbeFrom.AddHours(1)] = 1, [ProbeFrom.AddHours(3)] = 2, [ProbeFrom.AddHours(5)] = 3, + }; + Assert.Equal(expected, oracle); + Assert.Equal(oracle, probe!.ToDictionary(b => b.HourUtc, b => b.Servers)); + Assert.Equal(oracle, scan!.ToDictionary(b => b.HourUtc, b => b.Servers)); + Assert.All(probe!, b => Assert.Equal(0L, b.Passes)); + Assert.Equal(4, probe!.Count); + + /* The audited day 2026-03-04 settles on a bucket at or after 23:00: the empty hours must not read as one. */ + Assert.False(CollectionHistoryAudit.IsSettled(ProbeFrom, probe!)); + } + + ok = true; + } + finally + { + await LiveStoreCleanup.RunOwnedAsync(ok, async () => await scratch.DisposeAsync()); + } + } + + [Fact] + public async Task Live_TheProbeRead_IsANestedLoopOverTheServerIdBucketIndex_OnATimestampSeries_AgainstARealTimeFreeView() + { + Assert.SkipWhen(string.IsNullOrEmpty(Env()), "Set DARLING_TEST_PG to run the #5461 live tests."); + var ok = false; + var scratch = await ScratchPostgres.CreateAsync(Env()!, Ct); + try + { + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(Ct); + await SeedProbeStoreAsync(connection); + + foreach (var view in new[] { TimescaleSupport.QueryStoreStatsHourlyView, TimescaleSupport.QueryStoreStatsCorrectedHourlyView }) + { + /* The probe reads the materialization only: the view is not real-time, and bucket is a timestamp + (the series is a timestamp series, so no cross-type compare keeps the index out). */ + await using (var meta = new NpgsqlCommand(@" +SELECT ca.materialized_only, ca.materialization_hypertable_schema, ca.materialization_hypertable_name, + (SELECT data_type FROM information_schema.columns + WHERE table_schema = ca.materialization_hypertable_schema AND table_name = ca.materialization_hypertable_name AND column_name = 'bucket') +FROM timescaledb_information.continuous_aggregates AS ca +WHERE ca.view_schema = 'collect' AND ca.view_name = $1", connection)) + { + meta.Parameters.AddWithValue(view); + await using var reader = await meta.ExecuteReaderAsync(Ct); + Assert.True(await reader.ReadAsync(Ct)); + Assert.True(reader.GetBoolean(0), view + " must be materialized_only: the probe reads the materialization alone (#5461)"); + Assert.Equal("timestamp without time zone", reader.GetString(3)); + } + + var sql = CollectionHistoryAudit.BuildSql(new Rollup("collect." + view, false, CollectionHistoryAudit.ReadShape.Probe)); + await using var transaction = await connection.BeginTransactionAsync(Ct); + + /* The rig's data is a few rows, where the planner prefers a seq scan of the materialization; the + rig proves the index path EXISTS for this statement, and the big store proves its cost. */ + await ExecAsync(connection, "SET LOCAL enable_seqscan = off"); + + string json; + await using (var explain = new NpgsqlCommand("EXPLAIN (FORMAT JSON) " + sql, connection, transaction)) + { + explain.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Timestamp, Value = DateTime.SpecifyKind(ProbeFrom, DateTimeKind.Unspecified) }); + explain.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Timestamp, Value = DateTime.SpecifyKind(ProbeFrom.AddHours(200), DateTimeKind.Unspecified) }); + json = (string)(await explain.ExecuteScalarAsync(Ct))!; + } + + var nodes = new List(); + void Walk(System.Text.Json.JsonElement node) + { + nodes.Add(node); + if (node.TryGetProperty("Plans", out var plans)) + { + foreach (var child in plans.EnumerateArray()) + { + Walk(child); + } + } + } + + Walk(System.Text.Json.JsonDocument.Parse(json).RootElement[0].GetProperty("Plan")); + string Text(System.Text.Json.JsonElement n, string name) => n.TryGetProperty(name, out var v) ? v.GetString() ?? "" : ""; + + Assert.Contains(nodes, n => Text(n, "Node Type") == "Nested Loop"); + var indexScans = nodes.Where(n => Text(n, "Node Type") is "Index Scan" or "Index Only Scan" + && Text(n, "Relation Name").StartsWith("_hyper_", StringComparison.Ordinal)).ToList(); + Assert.NotEmpty(indexScans); + Assert.DoesNotContain(nodes, n => Text(n, "Node Type") == "Seq Scan" + && Text(n, "Relation Name").StartsWith("_hyper_", StringComparison.Ordinal)); + + foreach (var scan in indexScans) + { + /* The index is the kept (server_id, bucket) one, probed on both columns with no cross-type cast. */ + await using var def = new NpgsqlCommand("SELECT indexdef FROM pg_indexes WHERE indexname = $1", connection, transaction); + def.Parameters.AddWithValue(Text(scan, "Index Name")); + var indexDef = (string?)await def.ExecuteScalarAsync(Ct); + Assert.Contains("(server_id, bucket", indexDef, StringComparison.Ordinal); + var cond = Text(scan, "Index Cond"); + Assert.Contains("server_id", cond, StringComparison.Ordinal); + Assert.Contains("bucket", cond, StringComparison.Ordinal); + Assert.DoesNotContain("time zone", cond, StringComparison.Ordinal); + } + + await transaction.RollbackAsync(Ct); + } + + ok = true; + } + finally + { + await LiveStoreCleanup.RunOwnedAsync(ok, async () => await scratch.DisposeAsync()); + } + } +} diff --git a/Darling/Darling.Tests/CollectionLogDrainForensicsStoreTests.cs b/Darling/Darling.Tests/CollectionLogDrainForensicsStoreTests.cs index 3469d51fb9..d6ae955ecf 100644 --- a/Darling/Darling.Tests/CollectionLogDrainForensicsStoreTests.cs +++ b/Darling/Darling.Tests/CollectionLogDrainForensicsStoreTests.cs @@ -236,37 +236,40 @@ public void TheUnmeasuredSentinelIsNeverWrittenAsALiteral() } /// - /// The session id is captured AFTER the open round trip, exactly once, and the helper normalizes the - /// provider's not-populated 0 to null (#2884). - /// - /// SqlConnection.ServerProcessId is not reliably populated until the connection has round-tripped - /// a command. The original capture sat before ExecuteReaderAsync, so the runs it mattered most for — - /// budget-abandoned cycles, where the id is the join key to waiting_tasks and the peer snapshots — - /// recorded a literal 0. Ordering is pinned on the source because it IS a source property: the capture - /// call must come after the ExecuteReaderAsync await it depends on, and a refactor that hoists it back - /// above the open reintroduces #2884 while compiling clean and passing every behavioral test that - /// cannot construct a real SqlConnection. + /// The session id is resolved right after the target connection opens and BEFORE any reader exists on + /// it, through the per-connection cache (#5132). Under MultipleActiveResultSets + /// SqlConnection.ServerProcessId is 0 at every point, so the old capture after + /// ExecuteReaderAsync recorded nothing; the open-time lookup is the only source. The ordering and + /// the single call site are source properties, pinned here. /// [Fact] - public void TheSessionIdIsCapturedAfterTheOpenRoundTrip() + public void TheSessionIdIsResolvedRightAfterTheOpen_BeforeAnyReader() { var runner = ReadSource("Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs"); - const string openCall = "opened = await command.ExecuteReaderAsync("; - const string captureCall = "context.TargetSessionId = TryReadTargetSessionId("; - + const string openCall = "await targetConnection.OpenAsync(cancellationToken);"; + const string captureCall = "await TargetSessionIdCache.Shared.ResolveAsync(targetConnection"; var open = runner.IndexOf(openCall, StringComparison.Ordinal); var capture = runner.IndexOf(captureCall, StringComparison.Ordinal); Assert.True(open >= 0, "the server-scoped open call moved; re-anchor this pin"); - Assert.True(capture >= 0, "the session-id capture is gone entirely"); - Assert.True(capture > open, - "the session id must be captured AFTER ExecuteReaderAsync has round-tripped (#2884) — " + - "before it, SqlConnection.ServerProcessId reads 0 on exactly the abandoned cycles it exists to explain"); + Assert.True(capture > open, "the session-id lookup must follow the open"); + + /* BOTH reader call sites on the connection come after the capture: the enumerated path's list + reader and the plain path's reader. */ + foreach (var readerCall in new[] { "ExecuteReaderAsync(cancellationToken)", "opened = await command.ExecuteReaderAsync(itemToken)" }) + { + var reader = runner.IndexOf(readerCall, open, StringComparison.Ordinal); + Assert.True(reader > 0, $"the reader call '{readerCall}' moved; re-anchor this pin"); + Assert.True(capture < reader, $"the session-id lookup must precede '{readerCall}'"); + } + Assert.Equal(capture, runner.LastIndexOf(captureCall, StringComparison.Ordinal)); - /* The helper's own normalization: 0 is not a session id and must become null at the source. */ - Assert.Contains("return raw > 0 ? raw : null;", runner, StringComparison.Ordinal); + /* The reconnect check sits right after the plain path's first reader, never before it. */ + var plainReader = runner.IndexOf("opened = await command.ExecuteReaderAsync(itemToken)", StringComparison.Ordinal); + var discard = runner.IndexOf("TargetSessionIdCache.Shared.DiscardIfReplaced(", StringComparison.Ordinal); + Assert.True(discard > plainReader && plainReader > 0, "the replaced-connection check must follow the first reader's open"); } /// @@ -302,7 +305,7 @@ public void ThePeerMarkIsCapturedAtDispatchNotAtCompletion() var worker = ReadSource("Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs"); Assert.Contains("var peerMaxAtDispatchMs = PeerMaxOrNull(server);", worker, StringComparison.Ordinal); - Assert.Contains("RunDetachedAsync(server, runner, name, peerMaxAtDispatchMs, cancellationToken)", worker, StringComparison.Ordinal); + Assert.Contains("RunDetachedAsync(server, runner, name, peerMaxAtDispatchMs, cancellationToken", worker, StringComparison.Ordinal); /* The write uses the captured parameter. Re-reading live server state here is the defect. @@ -318,6 +321,15 @@ no pin. This form is adjacency-independent on the left and still pinned on the r Assert.Contains("result.FetchPhases, peerMaxAtDispatchMs, _logger", worker, StringComparison.Ordinal); Assert.DoesNotContain("PeerMaxOrNull(server), _logger", worker, StringComparison.Ordinal); + /* #5320: the statement text refresh is not a scheduled body either - it rides the statistics collector's run and + writes its own failure and success rows from outside RunOneAsync, with no dispatch mark to carry and no + drain to describe. The counts below are about the rows RunOneAsync writes, so that method is cut out of + the text they read; the live-re-read pin above still reads the whole file. */ + var refreshStart = worker.IndexOf("private async Task TryRefreshPgStatementTextAsync(", StringComparison.Ordinal); + var refreshEnd = worker.IndexOf("private static async Task<(List QueryIds", refreshStart, StringComparison.Ordinal); + Assert.True(refreshStart > 0 && refreshEnd > refreshStart, "#5320: the text refresh method moved; update this carve-out"); + worker = worker.Remove(refreshStart, refreshEnd - refreshStart); + /* The two callers that are not a scheduled body pass null rather than folding a previous body's bookkeeping into their rows. */ Assert.Equal(2, Regex.Matches(worker, @"peerMaxAtDispatchMs: null").Count); @@ -341,13 +353,18 @@ bookkeeping into their rows. */ route can read. The pin is what turned that into a merge conflict to resolve rather than a wrong number nobody noticed. #4053's no-csvlog-file arm is the twelfth, the csvlog route's twin of the eleventh: the collector is on and csvlog is configured, but no .csv file exists yet. #4053's - no-jsonlog-file arm is the thirteenth, the jsonlog route's twin of the twelfth. */ - Assert.Equal(13, Regex.Matches(worker, @"drain: null").Count); - - /* And V110's fetch sums stay null on those same thirteen arms and for the same reason: no item + no-jsonlog-file arm is the thirteenth, the jsonlog route's twin of the twelfth. #5378's + long-query PERMISSIONS arm is the fourteenth: the session's create was denied (the login lacks + ALTER ANY EVENT SESSION), so the run is an early return that read and drained nothing. The RDS + endpoint-check arm is the fifteenth: the run stops before any RDS or Performance Insights call, so it read and + drained nothing either. The AWS role arm (#5452) is the sixteenth, for the same reason: a role the service cannot + use stops the run before any RDS call. */ + Assert.Equal(16, Regex.Matches(worker, @"drain: null").Count); + + /* And V110's fetch sums stay null on those same sixteen arms and for the same reason: no item completed, so no fetch was performed. Counted rather than merely present, so an arm that starts passing a real value - which would mean attributing another run's fetch to a failure row - is a red. */ - Assert.Equal(13, Regex.Matches(worker, @"fetchPhases: null").Count); + Assert.Equal(16, Regex.Matches(worker, @"fetchPhases: null").Count); } /// diff --git a/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs b/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs index ec474ec7b6..f79b782221 100644 --- a/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs +++ b/Darling/Darling.Tests/CollectionLogFanoutRollupStoreTests.cs @@ -32,6 +32,7 @@ namespace Darling.Tests; /// is the arithmetic. Both aggregate over RUNS, /// and each of those runs is one blended row. /// +[Trait("Reads", "Lite")] public class CollectionLogFanoutRollupStoreTests { [Fact] diff --git a/Darling/Darling.Tests/CollectionLogSegmentByLiveTests.cs b/Darling/Darling.Tests/CollectionLogSegmentByLiveTests.cs new file mode 100644 index 0000000000..a60aeabb5c --- /dev/null +++ b/Darling/Darling.Tests/CollectionLogSegmentByLiveTests.cs @@ -0,0 +1,722 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Analysis; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4951: collection_log compresses segmented by collector as well as by server, so a read for one collector +/// decompresses only that collector's batches instead of every collector's runs on the server. +/// +/// Each test mints its own scratch database: the chunk census and the plan shape are statements about one +/// store's chunks, and another class's leftovers would change them. +/// +/// The expected segmentby is a literal here on purpose. These tests pin the value the store ends up with, so they +/// must not move when the product's constant does. +/// +/// The "upgraded" stores are built with the statements every build before #4951 ran (create_hypertable, then +/// the enable ALTER with server_id alone), with their older chunks compressed under those settings. +/// +[Collection("live-postgres")] +public sealed class CollectionLogSegmentByLiveTests +{ + /// What the store must end with, in the spelling the helper below reads it back in. + private const string WantedSegmentBy = "server_id, collector_name"; + + /// What every store converted before #4951 has. + private const string OldSegmentBy = "server_id"; + + /// The same two values as the per-chunk view spells them (comma, no space). + private const string WantedChunkSegmentBy = "server_id,collector_name"; + private const string OldChunkSegmentBy = "server_id"; + + private const int ServerA = -495101; + private const int ServerB = -495102; + private const string ServerName = "SEGMENTBY-SRV"; + private const string HeldCollectorColumn = "collector_name_held"; + + /// Well below any id the generator hands out, so a seeded row can never collide with a real one. + private const long LogIdBase = -495_100_000_000L; + + private static readonly string[] Collectors = { "blocked_process_report", "wait_stats", "cpu_utilization" }; + + /// The plan test's analysis hour, in the past, so every seeded chunk is old enough to compress. It is the + /// coverage-read plan class's own hour, because that class's EXPLAIN helper reads the window at it. + private static readonly DateTime AnalysisTime = DarlingEventBaselineCoverageReadPlanShapeLiveTests.AnalysisTime; + + /// Inside the plan test's baseline window. + private static readonly DateTime EventTime = DarlingEventBaselineCoverageReadPlanShapeLiveTests.EventTime; + + /* ---------------- fresh stores (condition 1) ---------------- */ + + /// + /// The usual fresh store: the migrations run before CREATE EXTENSION, so V23's guard skips and the runtime ensure + /// both converts collection_log and sets its compression. This one collected on plain PostgreSQL for days before + /// the extension arrived, so the conversion moves rows old enough to compress at once. The ensure's first ALTER + /// must already be the new setting, so the policy's first run compresses every one of those chunks with it, and + /// the store never compresses a chunk under server_id alone. + /// + [Fact] + public async Task AFreshStore_WhoseExtensionComesAfterTheMigrations_GetsTheNewSegmentBy_OnItsFirstConversion() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + + Assert.SkipWhen( + await ScalarAsync(connection, "SELECT count(*) FROM pg_extension WHERE extname = 'timescaledb'", ct) > 0, + "this cluster's template database already carries TimescaleDB, so a new database cannot start without it; the extension-first test covers that order."); + + await PgMigrations.MigrateAsync(connection, ct); + Assert.False((await ReadHypertableAsync(connection, ct)).IsHypertable, + "V23 converted collection_log on a store without the extension; this test needs the usual order"); + + /* Six days of runs, written while collection_log is still a plain table. */ + var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + await SeedAsync(connection, "collector_name", new[] { ServerA, ServerB }, now.Date.AddDays(-6), now.AddMinutes(-15), ct); + var dataBefore = await DataHashAsync(connection, "collector_name", ct); + + Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct), + "TimescaleDB is not available on this cluster."); + await StopBackgroundWorkersAsync(connection, ct); + await RequirePerChunkSettingsAsync(connection, ct); + + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + + var state = await ReadHypertableAsync(connection, ct); + Assert.True(state.IsHypertable); + Assert.True(state.CompressionEnabled); + Assert.Equal(WantedSegmentBy, state.SegmentBy); + Assert.Contains(TimescaleSupport.CollectionLogTable, await ConvergedTablesAsync(connection, ct)); + + /* The policy's first run compresses the converted rows' older chunks, every one with the new value, and + changes no row. */ + await RunCompressionPolicyAsync(connection, ct); + var chunks = await ReadChunkSettingsAsync(connection, ct); + Assert.NotEmpty(chunks); + Assert.All(chunks, chunk => Assert.Equal(WantedChunkSegmentBy, chunk.SegmentBy)); + Assert.Equal(dataBefore, await DataHashAsync(connection, "collector_name", ct)); + } + + /// + /// The other fresh-store order: TimescaleDB is installed before the migrations run (a bring-your-own store whose + /// administrator created it first), so V23 does the first conversion, with the server_id its text has always + /// had (a migration is never edited). V23's policy has no start time, so the scheduler runs it as soon as it is + /// added; on a new store that run finds an empty table. The first start's ensure then moves the hypertable to the + /// new value before any row is old enough to compress, so every chunk the policy compresses later uses the new + /// value and none uses server_id alone. + /// + [Fact] + public async Task AFreshStore_WhoseExtensionPredatesTheMigrations_HasTheNewSegmentBy_AfterItsFirstStart_AndNoServerIdOnlyChunk() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + + Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct), + "TimescaleDB is not available on this cluster."); + + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await StopBackgroundWorkersAsync(connection, ct); + await RequirePerChunkSettingsAsync(connection, ct); + await PgMigrations.MigrateAsync(connection, ct); + + /* V23's own work: converted, compression on, with its text's server_id. */ + var afterMigrations = await ReadHypertableAsync(connection, ct); + Assert.True(afterMigrations.IsHypertable, "V23 did not convert collection_log although the extension existed when it ran"); + Assert.True(afterMigrations.CompressionEnabled); + Assert.Equal(OldSegmentBy, afterMigrations.SegmentBy); + + /* V23's policy's immediate first run (the background workers are stopped, so it runs here). A new store's + table is empty, so it compresses nothing under server_id. */ + await RunCompressionPolicyAsync(connection, ct); + Assert.Empty(await ReadChunkSettingsAsync(connection, ct)); + + /* The first start. */ + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + Assert.Contains(TimescaleSupport.CollectionLogTable, await ConvergedTablesAsync(connection, ct)); + + /* Days of runs later, the policy compresses the older chunks, every one with the new value. */ + var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + await SeedAsync(connection, "collector_name", new[] { ServerA, ServerB }, now.Date.AddDays(-6), now.AddMinutes(-15), ct); + await RunCompressionPolicyAsync(connection, ct); + var chunks = await ReadChunkSettingsAsync(connection, ct); + Assert.NotEmpty(chunks); + Assert.All(chunks, chunk => Assert.Equal(WantedChunkSegmentBy, chunk.SegmentBy)); + } + + /* ---------------- upgraded stores ---------------- */ + + /// + /// An upgraded store whose chunks are already compressed under server_id. The ensure moves the hypertable to + /// the new value, leaves every compressed chunk as it was, and from then on reads as converged WHILE those old + /// chunks remain, so the hourly pass issues no ALTER. The settings read looks at the hypertable's own row, never at + /// a chunk's. + /// + [Fact] + public async Task AnUpgradedStore_MovesTheHypertableToTheNewSegmentBy_LeavesOldChunksAlone_AndReadsAsConverged() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct); + + var chunksBefore = await ReadChunkSettingsAsync(connection, ct); + Assert.NotEmpty(chunksBefore); + Assert.All(chunksBefore, chunk => Assert.Equal(OldChunkSegmentBy, chunk.SegmentBy)); + var dataBefore = await DataHashAsync(connection, "collector_name", ct); + + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + + Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct)); + Assert.Equal(dataBefore, await DataHashAsync(connection, "collector_name", ct)); + Assert.Contains(TimescaleSupport.CollectionLogTable, await ConvergedTablesAsync(connection, ct)); + + /* A second pass is a no-op on the same store. */ + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct)); + } + + /// + /// The pin behind "a converged store takes no ACCESS EXCLUSIVE lock each hour". With the setting converged and old + /// chunks still present, another session holds a lock that any ALTER would have to wait behind. The pass must + /// finish without waiting and report success: had it issued the ALTER, it would have waited out its lock timeout + /// and reported the busy lock instead. + /// + [Fact] + public async Task AConvergedStore_IssuesNoAlter_SoAHeldLockDoesNotHoldUpTheHourlyPass() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct); + + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + + await using var holder = new NpgsqlConnection(scratch.ConnectionString); + await holder.OpenAsync(ct); + await using var hold = await holder.BeginTransactionAsync(ct); + await ExecAsync(holder, "SELECT count(*) FROM collection_log", ct, hold); + + var logger = new CapturingTestLogger(); + using var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct); + bounded.CancelAfter(TimeSpan.FromSeconds(60)); + var elapsed = Stopwatch.StartNew(); + var ok = await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, bounded.Token); + elapsed.Stop(); + + Assert.True(ok, $"a converged pass did not finish cleanly beside a reader's lock (it took {elapsed.Elapsed}): {logger.Joined}"); + Assert.Equal(0, logger.CountAtLevel(Microsoft.Extensions.Logging.LogLevel.Warning)); + Assert.DoesNotContain(logger.Lines, line => line.Contains("compression settings", StringComparison.Ordinal)); + Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + + await hold.RollbackAsync(ct); + } + + /* ---------------- the lock (M1) ---------------- */ + + /// + /// A long reader holds collection_log while the change is due. The ALTER must give up at its lock timeout instead + /// of waiting (and queueing every collector's insert behind it): the pass returns false, logs at Information that + /// the next hourly pass tries again, changes nothing, and leaves the connection usable. Once the reader lets go, + /// the next pass applies the change. + /// + [Fact] + public async Task AHeldLock_BoundsTheAlter_TheStoreIsUnchanged_AndTheNextPassAppliesIt() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct); + var chunksBefore = await ReadChunkSettingsAsync(connection, ct); + + await using var holder = new NpgsqlConnection(scratch.ConnectionString); + await holder.OpenAsync(ct); + var hold = await holder.BeginTransactionAsync(ct); + await ExecAsync(holder, "SELECT count(*) FROM collection_log", ct, hold); + + var logger = new CapturingTestLogger(); + bool ok; + var elapsed = Stopwatch.StartNew(); + using (var bounded = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + bounded.CancelAfter(TimeSpan.FromSeconds(60)); + ok = await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, bounded.Token); + } + + elapsed.Stop(); + + Assert.False(ok, $"the pass reported success while another session held collection_log: {logger.Joined}"); + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(30), $"the ALTER waited {elapsed.Elapsed} behind the held lock: {logger.Joined}"); + Assert.Contains(logger.Lines, line => + line.StartsWith("Information:", StringComparison.Ordinal) + && line.Contains("collection_log's compression settings", StringComparison.Ordinal) + && line.Contains("next hourly pass tries again", StringComparison.Ordinal)); + Assert.Equal(0, logger.CountAtLevel(Microsoft.Extensions.Logging.LogLevel.Warning)); + Assert.Equal(OldSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct)); + Assert.Equal(1, await ScalarAsync(connection, "SELECT 1", ct)); + + await hold.RollbackAsync(ct); + await hold.DisposeAsync(); + + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + Assert.Equal(WantedSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + } + + /* ---------------- any other failure (condition 2) ---------------- */ + + /// + /// The ALTER fails for a reason other than a lock: here the store's collector column has another name, so the new + /// segmentby names a column that does not exist. The change rolls back and leaves the store exactly as it was. The + /// log says the settings change failed and the table keeps its settings, not that "setup failed" and the table + /// "stays a plain table" (it is a hypertable). The failure does not cost the pass its policy step: the table is + /// still compressible under its current setting, and no other step adds a missing compression policy. The + /// connection stays usable for the step after it, and the next pass tries again rather than remembering the failure. + /// + [Fact] + public async Task AFailedAlter_LeavesTheStoreAsItWas_LogsWhatHappened_AndIsRetriedNextPass() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await BuildUpgradedLogAsync(connection, scratch.ConnectionString, HeldCollectorColumn, ct); + + var chunksBefore = await ReadChunkSettingsAsync(connection, ct); + Assert.NotEmpty(chunksBefore); + var dataBefore = await DataHashAsync(connection, HeldCollectorColumn, ct); + Assert.Equal(0, await CompressionPolicyCountAsync(connection, ct)); + + var logger = new CapturingTestLogger(); + Assert.False(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, ct), + $"the pass reported success although the new segmentby names a column this store does not have: {logger.Joined}"); + + Assert.Equal(1, CountSettingsFailures(logger)); + Assert.DoesNotContain("plain table", logger.Joined, StringComparison.Ordinal); + Assert.Equal(OldSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct)); + Assert.Equal(dataBefore, await DataHashAsync(connection, HeldCollectorColumn, ct)); + Assert.True(1 == await CompressionPolicyCountAsync(connection, ct), + $"the failed settings change skipped the policy step, so a store without a compression policy never gets one: {logger.Joined}"); + + /* The connection is not left inside a failed transaction: the next statement and the next step both run. */ + Assert.Equal(1, await ScalarAsync(connection, "SELECT 1", ct)); + await TimescaleSupport.ConvergeCompressionScheduleAsync(connection, null, ct); + + /* The next pass tries again. */ + Assert.False(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, logger, ct)); + Assert.Equal(2, CountSettingsFailures(logger)); + Assert.Equal(chunksBefore, await ReadChunkSettingsAsync(connection, ct)); + } + + /* ---------------- answers and plan ---------------- */ + + /// + /// "New chunks only" leaves old and new chunks mixed until retention removes the old ones. Every read below must + /// return the same rows over the mix as it did when all chunks had the old settings. + /// + [Fact] + public async Task OldAndNewChunks_Mixed_ReturnTheSameRowsAsBefore() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await BuildUpgradedLogAsync(connection, scratch.ConnectionString, "collector_name", ct); + + var asOf = await ScalarAsync(connection, "SELECT max(collection_time) FROM collection_log", ct); + var before = await RunReadsAsync(connection, asOf, ct); + + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + await CompressChunksAsync(connection, olderThanDays: 1, ct); + + var segmentBys = (await ReadChunkSettingsAsync(connection, ct)).Select(chunk => chunk.SegmentBy).Distinct().ToList(); + Assert.Contains(OldChunkSegmentBy, segmentBys); + Assert.Contains(WantedChunkSegmentBy, segmentBys); + + var after = await RunReadsAsync(connection, asOf, ct); + Assert.Equal(before.Keys, after.Keys); + foreach (var (read, rows) in before) + { + Assert.True(rows == after[read], $"'{read}' returned different rows once old and new chunks were mixed"); + } + } + + /// + /// The read this change is for. The blocking baseline's coverage read (the logged CTE) asks for one + /// collector on one server; with collector_name in the segmentby, that condition must reach the compressed chunk's + /// own scan, so only that collector's batches are decompressed. Before the change only server_id reached it, + /// and the collector was filtered after every collector's rows were decompressed. + /// + [Fact] + public async Task TheEventBaselineRead_DecompressesOnlyItsCollectorsSegment() + { + var baseConnectionString = RequireLivePostgres(); + var ct = TestContext.Current.CancellationToken; + await using var scratch = await ScratchPostgres.CreateAsync(baseConnectionString, ct); + using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(scratch.ConnectionString, ct), + "TimescaleDB is not available on this cluster; the compressed-chunk plan shape needs it."); + Assert.True(await TimescaleSupport.EnsureCollectionLogHypertableAsync(connection, null, ct)); + await StopBackgroundWorkersAsync(connection, ct); + await TimescaleSupport.EnsureBaselineFallbackViewsAsync(connection, null, ct); + + await SeedAsync(connection, "collector_name", new[] { ServerA }, AnalysisTime.AddDays(-35), AnalysisTime.AddMinutes(-15), ct); + await SeedEventAsync(connection, ServerA, ct); + await CompressChunksAsync(connection, olderThanDays: 1, ct); + Assert.True(await ScalarAsync(connection, + "SELECT count(*) FROM timescaledb_information.chunks WHERE hypertable_name = 'collection_log' AND is_compressed", ct) >= 1, + "the seed produced no compressed collection_log chunk, so the plan would prove nothing"); + + var plan = await ExplainBlockingBaselineAsync(connection, ServerA, ct); + var compressedScans = CompressedScans(plan).Where(scan => scan.GetProperty("Actual Loops").GetDouble() > 0).ToList(); + Assert.True(compressedScans.Count > 0, $"the plan reads no compressed chunk:\n{plan.GetRawText()}"); + Assert.True( + compressedScans.All(scan => Conditions(scan).Contains("(collector_name = ", StringComparison.Ordinal)), + $"the collector condition does not reach the compressed chunk's own scan:\n{plan.GetRawText()}"); + } + + /* ---------------- fixture ---------------- */ + + private static string RequireLivePostgres() + { + var connectionString = Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + Assert.SkipWhen(string.IsNullOrEmpty(connectionString), + "Set DARLING_TEST_PG to a Postgres connection string to run the collection_log segmentby tests (each mints its own scratch database)."); + return connectionString!; + } + + /// + /// Skips on a TimescaleDB these tests cannot run on. Before 2.14 a hypertable's compression settings cannot change + /// while compressed chunks exist, so the product keeps such a store on server_id by design; and a TimescaleDB + /// without the per-chunk settings view gives these tests nothing to read. Literal version, like the segmentby + /// literals above: the tests pin the floor rather than follow the product's constant. + /// + private static async Task RequirePerChunkSettingsAsync(NpgsqlConnection connection, CancellationToken ct) + { + var version = await TimescaleSupport.ReadTimescaleVersionAsync(connection, null, ct); + var hasView = await ScalarAsync(connection, + "SELECT to_regclass('timescaledb_information.chunk_compression_settings') IS NOT NULL", ct); + Assert.SkipUnless(version is not null && version >= new Version(2, 14) && hasView, + $"TimescaleDB {version?.ToString() ?? "(version unknown)"} predates per-chunk compression settings (2.14), which these tests read and change."); + } + + /// + /// collection_log as every build before #4951 left it: a hypertable compressed by server_id alone, six days + /// of runs from two servers and three collectors, and every chunk older than three days compressed. The two newest + /// closed chunks stay uncompressed, so a test can compress them under the new settings. + /// other than collector_name renames that column BEFORE the conversion, which is how the failure test makes + /// the new segmentby name a column the store does not have. + /// + private static async Task BuildUpgradedLogAsync(NpgsqlConnection connection, string connectionString, string collectorColumn, CancellationToken ct) + { + await PgMigrations.MigrateAsync(connection, ct); + + if (!string.Equals(collectorColumn, "collector_name", StringComparison.Ordinal)) + { + await ExecAsync(connection, $"ALTER TABLE collection_log RENAME COLUMN collector_name TO {collectorColumn}", ct); + } + + Assert.SkipUnless(await LiveTimescaleProbe.TryEnableAsync(connectionString, ct), "TimescaleDB is not available on this cluster."); + await StopBackgroundWorkersAsync(connection, ct); + await RequirePerChunkSettingsAsync(connection, ct); + + await ExecAsync(connection, TimescaleSupport.CreateHypertableSql(TimescaleSupport.CollectionLogTable, TimescaleSupport.CollectionLogTimeColumn), ct); + await ExecAsync(connection, "ALTER TABLE collection_log SET (timescaledb.compress, timescaledb.compress_segmentby = 'server_id')", ct); + + var now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + await SeedAsync(connection, collectorColumn, new[] { ServerA, ServerB }, now.Date.AddDays(-6), now.AddMinutes(-15), ct); + await CompressChunksAsync(connection, olderThanDays: 3, ct); + + Assert.Equal(OldSegmentBy, (await ReadHypertableAsync(connection, ct)).SegmentBy); + } + + /// One run every 15 minutes per server and collector; every tenth run failed, so status filters matter. + private static async Task SeedAsync( + NpgsqlConnection connection, string collectorColumn, int[] servers, DateTime from, DateTime to, CancellationToken ct) + { + using var command = new NpgsqlCommand( + $@"INSERT INTO collection_log (log_id, server_id, server_name, {collectorColumn}, collection_time, status, rows_collected) + SELECT $1 - row_number() OVER (ORDER BY s.server_id, c.collector, g.t), s.server_id, $2, c.collector, g.t, + CASE WHEN (extract(epoch FROM g.t)::bigint / 900) % 10 = 0 THEN 'ERROR' ELSE 'SUCCESS' END, + (extract(epoch FROM g.t)::bigint / 900) % 7 + FROM unnest($3::integer[]) AS s(server_id) + CROSS JOIN unnest($4::text[]) AS c(collector) + CROSS JOIN generate_series($5::timestamp, $6::timestamp, INTERVAL '15 minutes') AS g(t)", connection); + command.Parameters.AddWithValue(LogIdBase); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(servers); + command.Parameters.AddWithValue(Collectors); + command.Parameters.AddWithValue(from); + command.Parameters.AddWithValue(to); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task SeedEventAsync(NpgsqlConnection connection, int serverId, CancellationToken ct) + { + using var command = new NpgsqlCommand( + "INSERT INTO blocked_process_reports (blocked_report_id, collection_time, server_id, server_name, event_time) VALUES ($1, $2, $3, $4, $5)", + connection); + command.Parameters.AddWithValue(LogIdBase); + command.Parameters.AddWithValue(EventTime); + command.Parameters.AddWithValue(serverId); + command.Parameters.AddWithValue(ServerName); + command.Parameters.AddWithValue(EventTime); + await command.ExecuteNonQueryAsync(ct); + } + + /// Compresses every not-yet-compressed chunk that closed more than days ago. + private static async Task CompressChunksAsync(NpgsqlConnection connection, int olderThanDays, CancellationToken ct) + { + var chunks = new List(); + using (var list = new NpgsqlCommand( + "SELECT format('%I.%I', chunk_schema, chunk_name) FROM timescaledb_information.chunks " + + "WHERE hypertable_schema = 'collect' AND hypertable_name = 'collection_log' AND NOT is_compressed " + + "AND range_end <= now() - make_interval(days => $1) ORDER BY range_start", connection)) + { + list.Parameters.AddWithValue(olderThanDays); + await using var reader = await list.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + chunks.Add(reader.GetString(0)); + } + } + + foreach (var chunk in chunks) + { + await ExecAsync(connection, $"SELECT compress_chunk('{chunk}')", ct); + } + } + + private static Task StopBackgroundWorkersAsync(NpgsqlConnection connection, CancellationToken ct) => + ExecAsync(connection, "SELECT _timescaledb_functions.stop_background_workers()", ct); + + /// collection_log's compression policy jobs: one row per policy, and TimescaleDB allows at most one. + private const string CompressionPolicyJobsSql = + "FROM timescaledb_information.jobs WHERE proc_name = 'policy_compression' " + + "AND hypertable_schema = 'collect' AND hypertable_name = 'collection_log'"; + + private static Task CompressionPolicyCountAsync(NpgsqlConnection connection, CancellationToken ct) => + ScalarAsync(connection, "SELECT count(*) " + CompressionPolicyJobsSql, ct); + + /// One run of collection_log's compression policy in this session, as the scheduler runs it: it compresses + /// every chunk older than the policy's compress-after with the settings the hypertable has at that moment. + private static async Task RunCompressionPolicyAsync(NpgsqlConnection connection, CancellationToken ct) + { + int? jobId; + using (var find = new NpgsqlCommand("SELECT job_id " + CompressionPolicyJobsSql, connection)) + { + jobId = await find.ExecuteScalarAsync(ct) as int?; + } + + Assert.True(jobId.HasValue, "collection_log has no compression policy to run"); + await ExecAsync(connection, $"CALL run_job({jobId.Value})", ct); + } + + /* ---------------- reads of the store ---------------- */ + + private sealed record HypertableState(bool IsHypertable, bool CompressionEnabled, string? SegmentBy); + + private sealed record ChunkSetting(string Chunk, string SegmentBy); + + /// collection_log's own settings row: whether it is a hypertable, whether compression is on, and its + /// segmentby columns in order, joined with ", ". + private static async Task ReadHypertableAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var command = new NpgsqlCommand( + @"SELECT h.compression_enabled, + (SELECT string_agg(cs.attname, ', ' ORDER BY cs.segmentby_column_index) + FROM timescaledb_information.compression_settings AS cs + WHERE cs.hypertable_schema = h.hypertable_schema + AND cs.hypertable_name = h.hypertable_name + AND cs.segmentby_column_index IS NOT NULL) + FROM timescaledb_information.hypertables AS h + WHERE h.hypertable_schema = 'collect' AND h.hypertable_name = 'collection_log'", connection); + try + { + await using var reader = await command.ExecuteReaderAsync(ct); + if (!await reader.ReadAsync(ct)) + { + return new HypertableState(false, false, null); + } + + return new HypertableState(true, !reader.IsDBNull(0) && reader.GetBoolean(0), reader.IsDBNull(1) ? null : reader.GetString(1)); + } + catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable) + { + /* No extension, so no timescaledb_information schema: not a hypertable. */ + return new HypertableState(false, false, null); + } + } + + /// Every compressed chunk of collection_log with the segmentby it was compressed under, by chunk name. + private static async Task> ReadChunkSettingsAsync(NpgsqlConnection connection, CancellationToken ct) + { + using var command = new NpgsqlCommand( + "SELECT chunk::text, coalesce(segmentby, '') FROM timescaledb_information.chunk_compression_settings " + + "WHERE hypertable = 'collect.collection_log'::regclass ORDER BY chunk::text", connection); + var chunks = new List(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + chunks.Add(new ChunkSetting(reader.GetString(0), reader.GetString(1))); + } + + return chunks; + } + + /// The product's own convergence read: the collect tables whose compression is already what it wants. + private static async Task> ConvergedTablesAsync(NpgsqlConnection connection, CancellationToken ct) => + await TimescaleSupport.ReadTablesNeedingCompressionEnableAsync(connection, null, ct) + ?? throw new InvalidOperationException("the convergence read failed"); + + /// One md5 over every row, in log_id order: unchanged data gives the same hash. + private static Task DataHashAsync(NpgsqlConnection connection, string collectorColumn, CancellationToken ct) => + ScalarAsync(connection, + $"SELECT md5(string_agg(concat_ws('|', log_id, server_id, {collectorColumn}, collection_time, status, rows_collected), ',' ORDER BY log_id)) FROM collection_log", + ct); + + /// The read shapes the change can affect, each rendered to one string: every row, a per-collector window, the + /// newest run per collector, a fleet-wide aggregate, and the "any earlier success" probe. + private static async Task> RunReadsAsync(NpgsqlConnection connection, DateTime asOf, CancellationToken ct) + { + var reads = new (string Name, string Sql, object[] Args)[] + { + ("every row", + "SELECT log_id, server_id, collector_name, collection_time, status, rows_collected FROM collection_log ORDER BY log_id", + Array.Empty()), + ("one collector, 5 days", + "SELECT collection_time, status, rows_collected FROM collection_log WHERE server_id = $1 AND collector_name = 'wait_stats' AND collection_time >= $2 - INTERVAL '5 days' ORDER BY collection_time, log_id", + new object[] { ServerA, asOf }), + ("newest run per collector", + "SELECT DISTINCT ON (collector_name) collector_name, collection_time, status FROM collection_log WHERE server_id = $1 ORDER BY collector_name, collection_time DESC, log_id DESC", + new object[] { ServerA }), + ("fleet aggregate", + "SELECT server_id, collector_name, count(*), min(collection_time), max(collection_time), sum(rows_collected), count(*) FILTER (WHERE status = 'SUCCESS') FROM collection_log WHERE collection_time <= $1 GROUP BY server_id, collector_name ORDER BY server_id, collector_name", + new object[] { asOf }), + ("any earlier success", + "SELECT EXISTS (SELECT 1 FROM collection_log WHERE server_id = $1 AND collector_name = 'cpu_utilization' AND status = 'SUCCESS' AND collection_time < $2 - INTERVAL '4 days')", + new object[] { ServerA, asOf }), + }; + + var results = new Dictionary(StringComparer.Ordinal); + foreach (var (name, sql, args) in reads) + { + using var command = new NpgsqlCommand(sql, connection); + foreach (var arg in args) + { + command.Parameters.AddWithValue(arg); + } + + var text = new StringBuilder(); + await using var reader = await command.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + for (var i = 0; i < reader.FieldCount; i++) + { + text.Append(reader.IsDBNull(i) ? "NULL" : Convert.ToString(reader.GetValue(i), System.Globalization.CultureInfo.InvariantCulture)).Append('|'); + } + + text.Append('\n'); + } + + results[name] = text.ToString(); + } + + return results; + } + + /// The blocking baseline as the provider runs it, through the coverage-read plan class's own EXPLAIN helper + /// (one copy of the six bound parameters, not two). + private static Task ExplainBlockingBaselineAsync(NpgsqlConnection connection, int serverId, CancellationToken ct) => + DarlingEventBaselineCoverageReadPlanShapeLiveTests.ExplainAsync( + connection, PgBaselineProvider.GetBaselineQuery(MetricNames.Blocking)!, serverId, ct); + + /// Every scan of a chunk's compressed relation, by the coverage-read plan class's own relation test. + private static List CompressedScans(JsonElement plan) + { + var found = new List(); + + void Visit(JsonElement node) + { + if (node.TryGetProperty("Relation Name", out var relation) + && DarlingEventBaselineCoverageReadPlanShapeLiveTests.IsCompressedRelation(relation.GetString()!)) + { + found.Add(node); + } + + if (node.TryGetProperty("Plans", out var children)) + { + foreach (var child in children.EnumerateArray()) + { + Visit(child); + } + } + } + + Visit(plan); + return found; + } + + /// A scan's own conditions and its descendants', by the coverage-read plan class's helper. On the old layout + /// the compressed scan's only collector condition is a bloom filter on a metadata column, which never contains + /// "(collector_name = ". + private static string Conditions(JsonElement node) => DarlingEventBaselineCoverageReadPlanShapeLiveTests.Conditions(node); + + /// Warning lines saying the settings change failed and the table kept its settings. + private static int CountSettingsFailures(CapturingTestLogger logger) => + logger.Lines.Count(line => + line.StartsWith("Warning:", StringComparison.Ordinal) + && line.Contains("collection_log's compression settings", StringComparison.Ordinal) + && line.Contains("could not be changed, so the change was rolled back", StringComparison.Ordinal) + && line.Contains("keeps its current settings", StringComparison.Ordinal)); + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct, NpgsqlTransaction? transaction = null) + { + using var command = new NpgsqlCommand(sql, connection, transaction); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task ScalarAsync(NpgsqlConnection connection, string sql, CancellationToken ct) + { + using var command = new NpgsqlCommand(sql, connection); + var value = await command.ExecuteScalarAsync(ct); + return (T)Convert.ChangeType(value!, typeof(T), System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/Darling/Darling.Tests/CollectionLogStoreProbeAttributionTests.cs b/Darling/Darling.Tests/CollectionLogStoreProbeAttributionTests.cs index ea45dc3458..7d7fdf3f0e 100644 --- a/Darling/Darling.Tests/CollectionLogStoreProbeAttributionTests.cs +++ b/Darling/Darling.Tests/CollectionLogStoreProbeAttributionTests.cs @@ -50,6 +50,7 @@ namespace Darling.Tests; /// every persisted column exactly as it was. These tests pin the arithmetic, the emit, the caveats on the two /// surfaces that drew the wrong inference, and the premise the FLOOR caveat rests on. /// +[Trait("Reads", "Lite")] public class CollectionLogStoreProbeAttributionTests { /// diff --git a/Darling/Darling.Tests/CollectionLogWebRowPreservesFullTextPinTests.cs b/Darling/Darling.Tests/CollectionLogWebRowPreservesFullTextPinTests.cs index 8469ef6dc5..8e197a523b 100644 --- a/Darling/Darling.Tests/CollectionLogWebRowPreservesFullTextPinTests.cs +++ b/Darling/Darling.Tests/CollectionLogWebRowPreservesFullTextPinTests.cs @@ -18,6 +18,7 @@ namespace Darling.Tests; /// old default through the row" contract #3897's trend tools pin for TrendBudget.Chart. No rig: a /// source-text pin, like PgCappedReadSurfaceTests. /// +[Trait("Stage", "Guard")] public sealed class CollectionLogWebRowPreservesFullTextPinTests { private const string WebEndpoints = diff --git a/Darling/Darling.Tests/CollectionNoteLatestRunTests.cs b/Darling/Darling.Tests/CollectionNoteLatestRunTests.cs index 0d59b51183..646c5cedb7 100644 --- a/Darling/Darling.Tests/CollectionNoteLatestRunTests.cs +++ b/Darling/Darling.Tests/CollectionNoteLatestRunTests.cs @@ -22,6 +22,7 @@ namespace Darling.Tests; /// window's. A note that carries counts is labelled as the latest run's and takes no run-count qualifier; /// a plain note keeps its qualifiers exactly. /// +[Trait("Reads", "Lite")] public class CollectionNoteLatestRunTests { private const string Counts = "shred_gated=2 events_read=0 report_xml_empty=0 report_xml_unparsed=0 events_stored=0"; diff --git a/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs b/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs index 9ef3911fef..b1d449b70b 100644 --- a/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs +++ b/Darling/Darling.Tests/CollectionOutputBesideCostTests.cs @@ -42,6 +42,7 @@ namespace Darling.Tests; /// deadlock_coverage established one level up: numbers, a denominator, a named cause, and a sentence /// that names both windows and disclaims the one it did not measure. /// +[Trait("Reads", "Lite")] public sealed class CollectionOutputBesideCostTests { /* The measured pg_deadlocks counts, so every row below is the shape this issue was filed on rather @@ -432,7 +433,9 @@ public void BothSkusReaders_MapTheSameOrdinals_ToTheSameFields() "public static async Task> GetCollectionHealthAsync"); var lite = OrdinalMap( Path.Combine("Lite", "Services", "LocalDataService.CollectionHealth.cs"), - "public async Task> GetCollectionHealthAsync"); + /* #5371 moved the positional reads out of the public GetCollectionHealthAsync (now the memo, + cancellation and phase-timing wrapper) into ReadCollectionHealthAsync, which maps the row. */ + "private async Task> ReadCollectionHealthAsync"); /* The precondition. 31 columns since #4748's one (latest_run_note, the newest run's note the band reads) - 30 at #3885's, 16 at #2460, plus #2472's four fan-out diff --git a/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs b/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs index 3eb84e8d55..6a49003986 100644 --- a/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs +++ b/Darling/Darling.Tests/CollectorDatabaseScopeRungTests.cs @@ -42,6 +42,7 @@ namespace Darling.Tests; /// really about being NEWEST — keeping a copy of those would assert this rung is still the top, /// which is how the NEXT rung's build goes red. /// +[Trait("Reads", "Lite")] public sealed class CollectorDatabaseScopeRungTests { private const int RungVersion = 125; @@ -473,9 +474,11 @@ public void BothProviders_SpliceTheScopeIntoTheDatabaseListPlan_AndTheMaintenanc /// /// The runner feeds ONE resolution per run to all three consumers — the dispatch probe, the /// per-database loop's list plan, and the enumeration context — and the stated boundaries hold: - /// the worker's delegate reads the SAME live overrides the cadence gate reads; the Azure XE - /// session provisioner deliberately passes NO scope (session inventory is server-shaped — three - /// collectors with three possible scopes share those sessions); and Lite's definition runner + /// the worker's delegate reads the SAME live overrides the cadence gate reads; the always-on Azure XE + /// session provisioner deliberately passes NO scope (session inventory is server-shaped — one list + /// provisions the deadlock and blocked-process sessions, whose collectors may carry different scopes), while the opt-in long-query trace + /// follows its own collector's scope through the runner's accessor (its lifecycle covers what its read + /// covers); and Lite's definition runner /// never names the scope at all, so every Lite query stays byte-identical to the unscoped form. /// [Fact] @@ -494,7 +497,8 @@ public void TheRunnerResolvesOnceAndFeedsAllThreeConsumers_AndTheStatedBoundarie worker, StringComparison.Ordinal); var xe = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingXeSessions.cs"); - Assert.Equal(2, CountOf(xe, "GetAzureDatabaseListAsync(server, databaseScope: null")); + Assert.Equal(1, CountOf(xe, "GetAzureDatabaseListAsync(server, databaseScope: null")); + Assert.Contains("runner.DatabaseScopeFor(LongQueryCompletionsCollector.Instance.Name, server.ServerId)", xe, StringComparison.Ordinal); var lite = RepoFile.ReadRepoFile("Lite", "Services", "RemoteCollectorService.DefinitionRunner.cs"); Assert.DoesNotContain("DatabaseScope", lite, StringComparison.Ordinal); diff --git a/Darling/Darling.Tests/CollectorFaultStackLogTests.cs b/Darling/Darling.Tests/CollectorFaultStackLogTests.cs new file mode 100644 index 0000000000..42f3488df6 --- /dev/null +++ b/Darling/Darling.Tests/CollectorFaultStackLogTests.cs @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Targets; +using Xunit; + +namespace Darling.Tests; + +/// +/// #5003: a failed collector run logged only the exception's message, so "Collection was modified; enumeration +/// operation may not execute" could not say which collection or which line. The first failure of each kind from each +/// collector now carries its full text; the repeats keep the one line. +/// +public class CollectorFaultStackLogTests +{ + private static Exception Thrown(Exception exception) + { + try + { + throw exception; + } + catch (Exception caught) + { + return caught; + } + } + + [Fact] + public void TheFirstFailureOfAKindCarriesTheTypeTheMessageAndTheFrameThatThrew() + { + var log = new CollectorFaultStackLog(); + + var text = log.TakeFirst("pg_log_events", Thrown(new InvalidOperationException("Collection was modified; enumeration operation may not execute."))); + + Assert.NotNull(text); + Assert.Contains("System.InvalidOperationException", text, StringComparison.Ordinal); + Assert.Contains("Collection was modified", text, StringComparison.Ordinal); + Assert.Contains(nameof(Thrown), text, StringComparison.Ordinal); + } + + [Fact] + public void ARepeatOfTheSameKindFromTheSameCollectorGetsNothing() + { + var log = new CollectorFaultStackLog(); + + Assert.NotNull(log.TakeFirst("pg_log_events", Thrown(new InvalidOperationException("one")))); + Assert.Null(log.TakeFirst("pg_log_events", Thrown(new InvalidOperationException("two")))); + Assert.Null(log.TakeFirst("pg_log_events", Thrown(new InvalidOperationException("three")))); + } + + [Fact] + public void AnotherCollectorOrAnotherTypeIsAnotherKind() + { + var log = new CollectorFaultStackLog(); + + Assert.NotNull(log.TakeFirst("pg_log_events", Thrown(new InvalidOperationException("x")))); + Assert.NotNull(log.TakeFirst("pg_deadlocks", Thrown(new InvalidOperationException("x")))); + Assert.NotNull(log.TakeFirst("pg_log_events", Thrown(new ArgumentException("x")))); + } + + /// + /// An RDS log read wraps every fault in . A network fault that came first + /// must not use up the stack for a bug inside the read that comes later, so the type underneath is part of the kind. + /// + [Fact] + public void AWrapperOverADifferentCauseIsAnotherKind() + { + var log = new CollectorFaultStackLog(); + + var network = new RdsLogUnavailableException("unreachable", false, new HttpRequestException("unreachable")); + var bug = new RdsLogUnavailableException( + "Collection was modified; enumeration operation may not execute.", false, Thrown(new InvalidOperationException("Collection was modified; enumeration operation may not execute."))); + + Assert.NotNull(log.TakeFirst("pg_log_events", Thrown(network))); + Assert.NotNull(log.TakeFirst("pg_log_events", Thrown(bug))); + Assert.Null(log.TakeFirst("pg_log_events", Thrown(bug))); + + var text = new CollectorFaultStackLog().TakeFirst("pg_log_events", Thrown(bug)); + Assert.Contains("System.InvalidOperationException", text, StringComparison.Ordinal); + } + + /// The failure arm is where an out-of-memory run lands, and building the text allocates. + [Fact] + public void AnOutOfMemoryFailureGetsNoStackAndUsesUpNothing() + { + var log = new CollectorFaultStackLog(); + + Assert.Null(log.TakeFirst("wait_stats", new OutOfMemoryException())); + Assert.Null(log.TakeFirst("wait_stats", new InsufficientMemoryException())); + Assert.Null(log.TakeFirst("wait_stats", new OutOfMemoryException())); + } + + /// + /// The RDS ingestors wrap every fault that is not a cancellation in , so an + /// out-of-memory read reaches the failure arm one level down. It takes the one-line arm like a bare one, wherever it + /// sits under the wrapper, and a wrapper over any other cause still gets its stack. + /// + [Fact] + public void AnOutOfMemoryUnderAWrapperGetsNoStackEither() + { + var log = new CollectorFaultStackLog(); + + var wrapped = new RdsLogUnavailableException("The log read failed.", false, new OutOfMemoryException()); + var deeper = new InvalidOperationException("outer", new RdsLogUnavailableException("The log read failed.", false, new InsufficientMemoryException())); + var outerOverOther = new OutOfMemoryException("outer", new HttpRequestException("unreachable")); + + Assert.Null(log.TakeFirst("pg_log_events", Thrown(wrapped))); + Assert.Null(log.TakeFirst("pg_log_events", Thrown(wrapped))); + Assert.Null(log.TakeFirst("pg_deadlocks", Thrown(deeper))); + Assert.Null(log.TakeFirst("pg_plan_capture", Thrown(outerOverOther))); + + var network = new RdsLogUnavailableException("unreachable", false, new HttpRequestException("unreachable")); + + Assert.NotNull(log.TakeFirst("pg_log_events", Thrown(network))); + } + + /// An exception whose own throws, as a type with a faulty override could. + private sealed class UnrenderableException : Exception + { + public UnrenderableException() : base("this exception cannot render itself") { } + + public override string ToString() => throw new InvalidOperationException("ToString failed"); + } + + /// + /// The kind is claimed after the text is built. When building it throws, the kind still logs once, with a short line + /// that names the exception and what threw, and the second failure of the kind gets nothing. + /// + [Fact] + public void AnExceptionWhoseTextCannotBeBuiltStillLogsItsKindOnceWithAShortLine() + { + var log = new CollectorFaultStackLog(); + + var first = log.TakeFirst("pg_log_events", Thrown(new UnrenderableException())); + + Assert.NotNull(first); + Assert.Contains(nameof(UnrenderableException), first, StringComparison.Ordinal); + Assert.Contains("System.InvalidOperationException", first, StringComparison.Ordinal); + Assert.DoesNotContain(Environment.NewLine, first, StringComparison.Ordinal); + + Assert.Null(log.TakeFirst("pg_log_events", Thrown(new UnrenderableException()))); + Assert.NotNull(log.TakeFirst("pg_deadlocks", Thrown(new UnrenderableException()))); + } + + [Fact] + public async Task EightRunsFailingTogetherInOneWayYieldExactlyOneStack() + { + var log = new CollectorFaultStackLog(); + using var start = new Barrier(8); + + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(i => Task.Factory.StartNew( + () => + { + start.SignalAndWait(); + return log.TakeFirst("pg_log_events", Thrown(new InvalidOperationException("run " + i))); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default))).WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal(1, results.Count(text => text is not null)); + } + + /// + /// The wiring, pinned from source because the failure arm needs a store to run. The one line every failure logs is + /// unchanged (StoreCopyPhaseTests pins its shape), the full text is a second line written only for the first + /// failure of a kind, and the timeout arm does not take it: its filter admits only a command timeout from the driver, + /// so a stack there would be driver frames, and the authored sentence already names everything that matters. + /// + [Fact] + public void OnlyTheGeneralFailureArmWritesTheFullTextAndItIsTheFirstOfAKindOnly() + { + var worker = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "DarlingWorker.cs"); + + Assert.Single(System.Text.RegularExpressions.Regex.Matches(worker, @"_collectorFaultStacks\.TakeFirst\(collectorName, ex\)")); + + var errorLine = worker.IndexOf("=> ERROR: {Message}\",", StringComparison.Ordinal); + var detail = worker.IndexOf("_collectorFaultStacks.TakeFirst(collectorName, ex)", StringComparison.Ordinal); + var dropRuntime = worker.IndexOf("ConnectionFaultDisposition.ShouldDropRuntimeAsync(", detail, StringComparison.Ordinal); + var timeoutArm = worker.IndexOf("=> ERROR (timeout): {Message}", StringComparison.Ordinal); + + Assert.True(timeoutArm > 0 && timeoutArm < errorLine, "The timeout arm comes before the general arm."); + Assert.True(errorLine < detail && detail < dropRuntime, "The full text is written after the one line and before the connection check."); + + /* The timeout arm and everything up to the general arm's own line: no full text there. */ + Assert.DoesNotContain("TakeFirst", worker[timeoutArm..errorLine], StringComparison.Ordinal); + + /* Same exception object, not a copy, and a LogError so the level is the arm's own. */ + Assert.Contains("_logger.LogError(ex,", worker[detail..dropRuntime], StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/CollectorHealthClassifierTests.cs b/Darling/Darling.Tests/CollectorHealthClassifierTests.cs index 79d5aff1dd..3c6f0664c5 100644 --- a/Darling/Darling.Tests/CollectorHealthClassifierTests.cs +++ b/Darling/Darling.Tests/CollectorHealthClassifierTests.cs @@ -196,7 +196,7 @@ public void Classify_LongDormantCollector_WithNoRecentActivityOfAnyKind_IsStoppe [InlineData("DarlingDataReader.CollectionHealthSql")] [InlineData("DarlingFleetReader.FleetCollectionHealthSql")] [InlineData("ViewerDataService.CollectionHealthSql")] - [InlineData("ViewerDataService.FleetCollectionHealthSql")] + [InlineData("ViewerDataService.FleetCollectionHealthByServerSql")] public void EveryBandingRead_SelectsTheAbandonedCount_SoNoSurfaceBandsOnADefaultedZero(string which) { var sql = which switch @@ -204,7 +204,7 @@ public void EveryBandingRead_SelectsTheAbandonedCount_SoNoSurfaceBandsOnADefault "DarlingDataReader.CollectionHealthSql" => DarlingDataReader.CollectionHealthSql, "DarlingFleetReader.FleetCollectionHealthSql" => DarlingFleetReader.FleetCollectionHealthSql, "ViewerDataService.CollectionHealthSql" => ViewerDataService.CollectionHealthSql, - "ViewerDataService.FleetCollectionHealthSql" => ViewerDataService.FleetCollectionHealthSql, + "ViewerDataService.FleetCollectionHealthByServerSql" => ViewerDataService.FleetCollectionHealthByServerSql, _ => throw new ArgumentOutOfRangeException(nameof(which), which, "unmapped banding read"), }; @@ -240,7 +240,7 @@ passed by matching nothing at all cannot hide here. */ [InlineData("DarlingDataReader.CollectionHealthSql")] [InlineData("DarlingFleetReader.FleetCollectionHealthSql")] [InlineData("ViewerDataService.CollectionHealthSql")] - [InlineData("ViewerDataService.FleetCollectionHealthSql")] + [InlineData("ViewerDataService.FleetCollectionHealthByServerSql")] public void EveryBandingRead_SelectsTheExtensionMissingCount_SoNoSurfaceBandsOnADefaultedZero(string which) { var sql = which switch @@ -248,7 +248,7 @@ public void EveryBandingRead_SelectsTheExtensionMissingCount_SoNoSurfaceBandsOnA "DarlingDataReader.CollectionHealthSql" => DarlingDataReader.CollectionHealthSql, "DarlingFleetReader.FleetCollectionHealthSql" => DarlingFleetReader.FleetCollectionHealthSql, "ViewerDataService.CollectionHealthSql" => ViewerDataService.CollectionHealthSql, - "ViewerDataService.FleetCollectionHealthSql" => ViewerDataService.FleetCollectionHealthSql, + "ViewerDataService.FleetCollectionHealthByServerSql" => ViewerDataService.FleetCollectionHealthByServerSql, _ => throw new ArgumentOutOfRangeException(nameof(which), which, "unmapped banding read"), }; @@ -562,17 +562,17 @@ public void EveryBandingRow_BandsWarning_OnTheNewestRunsNote_AndNotOnAnOlderOne( } /// Every read that builds a banding row selects the newest run's note, at the ordinal its mapper - /// reads; the viewer's fleet read gets it from plain aggregates (no window function), and the sentence its + /// reads (the per-server reads from the newest-run lookup, #4955, the status gate on the row it returns); + /// the viewer's fleet read gets it from plain aggregates (no window function), and the sentence its /// LIKE looks for is the one the writer writes. [Fact] public void EveryDarlingHealthRead_ProjectsTheNewestRunsNote() { - Assert.Contains("recency_rank = 1 AND status = 'SUCCESS' THEN error_message END) AS latest_run_note", DarlingDataReader.CollectionHealthSql, StringComparison.Ordinal); - Assert.Contains("AS latest_run_note", ViewerDataService.CollectionHealthSql, StringComparison.Ordinal); - Assert.Contains("AS recency_rank", ViewerDataService.CollectionHealthSql, StringComparison.Ordinal); - Assert.Contains("AS latest_run_note", ViewerDataService.FleetCollectionHealthSql, StringComparison.Ordinal); - Assert.DoesNotContain("ROW_NUMBER()", ViewerDataService.FleetCollectionHealthSql, StringComparison.Ordinal); - Assert.Contains(CollectionHealthRollupSupport.LatestRunNoteRawSql, ViewerDataService.FleetCollectionHealthSql, StringComparison.Ordinal); + Assert.Contains("CASE WHEN newest.status = 'SUCCESS' THEN newest.error_message END AS latest_run_note", DarlingDataReader.CollectionHealthSql, StringComparison.Ordinal); + Assert.Contains("CASE WHEN newest.status = 'SUCCESS' THEN newest.error_message END AS latest_run_note", ViewerDataService.CollectionHealthSql, StringComparison.Ordinal); + Assert.Contains("AS latest_run_note", ViewerDataService.FleetCollectionHealthByServerSql, StringComparison.Ordinal); + Assert.DoesNotContain("ROW_NUMBER()", ViewerDataService.FleetCollectionHealthByServerSql, StringComparison.Ordinal); + Assert.Contains(CollectionHealthRollupSupport.LatestRunNoteRawSql, ViewerDataService.FleetCollectionHealthByServerSql, StringComparison.Ordinal); Assert.Contains($"'%{PartialDatabaseFailureNote.Marker}%'", CollectionHealthRollupSupport.LatestRunNoteRawSql, StringComparison.Ordinal); Assert.Contains(PartialDatabaseFailureNote.Marker, PartialDatabaseFailureNote.Format, StringComparison.Ordinal); } diff --git a/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs b/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs index 0010bf6e5e..08a5fa8099 100644 --- a/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs +++ b/Darling/Darling.Tests/CollectorMeasurementSeamTests.cs @@ -47,6 +47,7 @@ namespace Darling.Tests; /// counts as SEPARATE pins, because a distinctness check over the combined message would pass on the /// numbers rather than on the verdicts. /// +[Trait("Reads", "Lite")] public class CollectorMeasurementSeamTests { /* A rendered note may contain NOTHING but label=value tokens separated by single spaces. Anchored at @@ -379,9 +380,11 @@ A quiet server and a server whose every captured report we failed to parse both MakeReader(new[] { null, "", ReportXml }), mixed, CancellationToken.None); Assert.Single(mixedRows); - Assert.Equal( - "events_read=3 report_xml_empty=1 report_xml_unparsed=1 events_stored=1", - CollectorMeasurementNote.Render(mixed.Measurements)); + /* #4348: the good report's statements went through the statement filter, so a cycle that judged a value + also carries the four statement_scrub_* measurements, after the collector's own. */ + var mixedNote = CollectorMeasurementNote.Render(mixed.Measurements); + Assert.StartsWith("events_read=3 report_xml_empty=1 report_xml_unparsed=1 events_stored=1 ", mixedNote, StringComparison.Ordinal); + Assert.Contains("statement_scrub_named=0 statement_scrub_timeouts=0 statement_scrub_unjudged=0 statement_scrub_ms=", mixedNote, StringComparison.Ordinal); /* The one that matters: the ring buffer delivered events and NOT ONE became a row. Indistinguishable from the quiet server above until this seam existed. */ @@ -584,6 +587,13 @@ this cycle. Both collectors declare their own const (same "shred_gated" string, from its own trailing result set), so only one need be listed here for the distinct-label set this assertion actually checks. */ BlockedProcessReportCollector.ShredGatedMeasurement, + /* #4348: the statement filter's four counters, measured by CollectorContext itself (the sessions a + collector opens with BeginStatementScrub), which is why the consts live in CollectorContext.cs + beside the four .Measure( calls. Written only when the cycle judged a value. */ + CollectorContext.StatementScrubNamedMeasurement, + CollectorContext.StatementScrubTimeoutsMeasurement, + CollectorContext.StatementScrubUnjudgedMeasurement, + CollectorContext.StatementScrubMsMeasurement, }.OrderBy(l => l, StringComparer.Ordinal).ToList(), resolved.Distinct(StringComparer.Ordinal).OrderBy(l => l, StringComparer.Ordinal).ToList()); } diff --git a/Darling/Darling.Tests/CollectorMemoryKnobTests.cs b/Darling/Darling.Tests/CollectorMemoryKnobTests.cs index 3944423895..23c67c52ad 100644 --- a/Darling/Darling.Tests/CollectorMemoryKnobTests.cs +++ b/Darling/Darling.Tests/CollectorMemoryKnobTests.cs @@ -135,6 +135,8 @@ making it public just to observe it. */ lockField.SetValue(worker, new object()); var loggerField = typeof(DarlingWorker).GetField("_logger", BindingFlags.NonPublic | BindingFlags.Instance)!; loggerField.SetValue(worker, NullLogger.Instance); + typeof(DarlingWorker).GetField("_collectorFaultStacks", BindingFlags.NonPublic | BindingFlags.Instance)! + .SetValue(worker, new CollectorFaultStackLog()); var reconcile = typeof(DarlingWorker).GetMethod("ReconcileSweepGate", BindingFlags.NonPublic | BindingFlags.Instance)!; var absorbed = typeof(DarlingWorker).GetField("_gateAbsorbed", BindingFlags.NonPublic | BindingFlags.Instance)!; diff --git a/Darling/Darling.Tests/CollectorRunTimeCliVerbTests.cs b/Darling/Darling.Tests/CollectorRunTimeCliVerbTests.cs new file mode 100644 index 0000000000..f2263fc974 --- /dev/null +++ b/Darling/Darling.Tests/CollectorRunTimeCliVerbTests.cs @@ -0,0 +1,485 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// The --set-collector-run-at verb (#4938), without a store: its spelling, its strict grammar, the command the +/// verb stands in for and the plan the executor makes of it (so the verb owns no SQL), the usage text, and the line +/// the read-back prints for a row that carries a run time. The verb run end to end is +/// . +/// +public sealed class CollectorRunTimeCliVerbTests +{ + private const string Verb = "--set-collector-run-at"; + + private static bool Parse(string[] rest, out string? collector, out string? runAt, out string? server, out string? config, out string? error) => + DarlingCliCommands.TryParseCollectorRunAtArgs(Verb, rest, out collector, out runAt, out server, out config, out error); + + [Fact] + public void TheVerb_IsRecognisedBySpelling_CaseInsensitively_AndNoOtherVerbIs() + { + Assert.True(DarlingCliCommands.IsSetCollectorRunAtVerb("--set-collector-run-at")); + Assert.True(DarlingCliCommands.IsSetCollectorRunAtVerb("--SET-COLLECTOR-RUN-AT")); + Assert.False(DarlingCliCommands.IsSetCollectorRunAtVerb("--set-collector-run")); + Assert.False(DarlingCliCommands.IsSetCollectorRunAtVerb("--enable-collector")); + Assert.False(DarlingCliCommands.IsEnableCollectorVerb("--set-collector-run-at")); + Assert.False(DarlingCliCommands.IsDisableCollectorVerb("--set-collector-run-at")); + } + + [Fact] + public void TheGrammar_IsACollectorAndATime_WithAnOptionalServerAndConfig_InAnyOrder() + { + Assert.True(Parse(new[] { "index_object_stats", "02:00" }, out var collector, out var runAt, out var server, out var config, out var error)); + Assert.Equal("index_object_stats", collector); + Assert.Equal("02:00", runAt); + Assert.Null(server); + Assert.Null(config); + Assert.Null(error); + + Assert.True(Parse(new[] { "--server", "sql01", "index_object_stats", "none", "--config", "darling.json" }, out collector, out runAt, out server, out config, out error)); + Assert.Equal("index_object_stats", collector); + Assert.Equal("none", runAt); + Assert.Equal("sql01", server); + Assert.Equal("darling.json", config); + + Assert.True(Parse(new[] { "index_object_stats", "default", "--server", "sql01" }, out _, out runAt, out server, out _, out _)); + Assert.Equal("default", runAt); + Assert.Equal("sql01", server); + } + + [Theory] + [InlineData(new string[0], "needs a collector name")] + [InlineData(new[] { "index_object_stats" }, "needs a time")] + [InlineData(new[] { "index_object_stats", "02:00", "extra" }, "takes ONE collector name and ONE time")] + [InlineData(new[] { "index_object_stats", "02:00", "--server" }, "--server needs a server name")] + [InlineData(new[] { "index_object_stats", "02:00", "--server", "--config", "x" }, "--server needs a server name")] + [InlineData(new[] { "index_object_stats", "02:00", "--config" }, "--config needs a path")] + [InlineData(new[] { "index_object_stats", "02:00", "--bogus" }, "Unknown option")] + public void TheGrammar_RefusesAnythingElse_NamingTheProblem(string[] rest, string expected) + { + Assert.False(Parse(rest, out _, out _, out _, out _, out var error)); + Assert.Contains(expected, error, StringComparison.Ordinal); + Assert.Contains(Verb, error, StringComparison.Ordinal); + } + + [Fact] + public void TheUsageText_NamesTheThreeValues_TheScopes_AndTheCollectors() + { + var text = DarlingCliCommands.CollectorRunAtUsageText(); + + Assert.Contains("--set-collector-run-at [--server ]", text, StringComparison.Ordinal); + Assert.Contains("HH:MM", text, StringComparison.Ordinal); + Assert.Contains("none", text, StringComparison.Ordinal); + Assert.Contains("default", text, StringComparison.Ordinal); + Assert.Contains("server time", text, StringComparison.Ordinal); + Assert.Contains("index_object_stats", text, StringComparison.Ordinal); + } + + private static CommandPlan Plan(string collector, string runAt, int? serverId) => + DarlingCliCommands.PlanCollectorRunAt(collector, runAt, serverId); + + [Fact] + public void TheVerbsCommand_IsTheCommandPlanesSetCollectorRunAt_Row() + { + var command = DarlingCliCommands.BuildCollectorRunAtCommand("index_object_stats", "02:00", 7); + + Assert.Equal("set_collector_run_at", command.CommandType); + Assert.Equal(7, command.TargetServerId); + Assert.Equal("cli", command.RequestedBy); + using var args = JsonDocument.Parse(command.ArgsJson!); + Assert.Equal("index_object_stats", args.RootElement.GetProperty("collector_name").GetString()); + Assert.Equal("02:00", args.RootElement.GetProperty("run_at").GetString()); + + /* The verb's plan is the executor's plan for that command, nothing else: one write path. */ + var viaExecutor = DarlingCommandExecutor.ResolvePlan(command); + var viaVerb = Plan("index_object_stats", "02:00", 7); + Assert.Equal(viaExecutor.Kind, viaVerb.Kind); + Assert.Equal(viaExecutor.Sql, viaVerb.Sql); + Assert.Equal(viaExecutor.Parameters, viaVerb.Parameters); + } + + private const string FleetUpsert = + "INSERT INTO config.config_collector_run_times (server_id, collector_name, run_at_minute) VALUES (NULL, $1, $2) " + + "ON CONFLICT (collector_name) WHERE server_id IS NULL DO UPDATE SET run_at_minute = EXCLUDED.run_at_minute"; + + private const string ServerUpsert = + "INSERT INTO config.config_collector_run_times (server_id, collector_name, run_at_minute) VALUES ($1, $2, $3) " + + "ON CONFLICT (server_id, collector_name) WHERE server_id IS NOT NULL DO UPDATE SET run_at_minute = EXCLUDED.run_at_minute"; + + [Fact] + public void ATimeOrNone_UpsertsOneRunTimeRow_AndTouchesNoScheduleRow() + { + var fleet = Plan("index_object_stats", "02:00", serverId: null); + Assert.Equal(CommandKind.StoreWrite, fleet.Kind); + Assert.Equal(FleetUpsert, fleet.Sql); + Assert.Equal(new object?[] { "index_object_stats", (short)120 }, fleet.Parameters); + Assert.Equal("collector run time set (fleet-wide)", fleet.SuccessStatus); + + var server = Plan("index_object_stats", "02:00", serverId: 7); + Assert.Equal(ServerUpsert, server.Sql); + Assert.Equal(new object?[] { 7, "index_object_stats", (short)120 }, server.Parameters); + Assert.Equal("collector run time set", server.SuccessStatus); + + /* None on a server is -1 in the same upsert. */ + var none = Plan("index_object_stats", "none", serverId: 7); + Assert.Equal(ServerUpsert, none.Sql); + Assert.Equal(new object?[] { 7, "index_object_stats", (short)-1 }, none.Parameters); + + /* The run time has its own table: no plan names a schedule row or its enabled flag, so a collector that ships OFF + is never switched on as a side effect, and the schedule rows are neither read nor written. */ + foreach (var plan in new[] { fleet, server, none, Plan("long_query_completions", "02:00", serverId: null) }) + { + Assert.DoesNotContain("config_collector_schedules", plan.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("enabled", plan.Sql, StringComparison.Ordinal); + } + } + + [Fact] + public void NoneOnTheFleet_AndDefault_DeleteTheScopesRow_WithoutCreatingOne() + { + const string FleetDelete = "DELETE FROM config.config_collector_run_times WHERE server_id IS NULL AND collector_name = $1"; + const string ServerDelete = "DELETE FROM config.config_collector_run_times WHERE server_id = $1 AND collector_name = $2"; + + /* The fleet has no time to stop, so none there is the same as default: the row goes. */ + foreach (var word in new[] { "none", "default", "Default", "NONE" }) + { + var fleet = Plan("index_object_stats", word, serverId: null); + Assert.Equal(FleetDelete, fleet.Sql); + Assert.Equal(new object?[] { "index_object_stats" }, fleet.Parameters); + Assert.Equal("collector run time cleared (fleet-wide)", fleet.SuccessStatus); + } + + var server = Plan("index_object_stats", "default", serverId: 7); + Assert.Equal(ServerDelete, server.Sql); + Assert.Equal(new object?[] { 7, "index_object_stats" }, server.Parameters); + Assert.Equal("collector run time cleared", server.SuccessStatus); + } + + [Theory] + [InlineData("24:00")] + [InlineData("2:5")] + [InlineData("noon")] + [InlineData("")] + public void ABadTime_IsRefusedByThePlan_WithTheSharedText(string runAt) + { + var plan = Plan("index_object_stats", runAt, serverId: null); + + Assert.Equal(CommandKind.Fail, plan.Kind); + Assert.Equal(CollectorRunTime.InvalidRunAtMessage, plan.FailReason); + } + + [Fact] + public void AnUnknownCollector_AndAMissingArgument_AreRefusedByThePlan() + { + Assert.Equal(CommandKind.Fail, Plan("not_a_collector", "02:00", serverId: null).Kind); + Assert.Contains("unknown collector", Plan("not_a_collector", "02:00", serverId: null).FailReason, StringComparison.Ordinal); + + Assert.Equal(CommandKind.Fail, DarlingCommandExecutor.ResolvePlan( + new ClaimedCommand(0, "set_collector_run_at", null, "{\"collector_name\":\"index_object_stats\"}", "cli")).Kind); + Assert.Equal(CommandKind.Fail, DarlingCommandExecutor.ResolvePlan( + new ClaimedCommand(0, "set_collector_run_at", null, "{\"run_at\":\"02:00\"}", "cli")).Kind); + } + + [Fact] + public void TheReadBack_PrintsTheRunTimeRows_UnderTheirOwnHeading_AndNothingForACollectorWithNone() + { + var schedule = new[] { new DarlingCliCommands.CollectorScheduleReadbackRow(null, "index_object_stats", null, null, true, null, null) }; + + /* No run times: the read-back is exactly the schedule read-back it always was. */ + var plain = DarlingCliCommands.FormatCollectorScheduleRows("index_object_stats", schedule); + Assert.DoesNotContain(plain, l => l.Contains("run_at=", StringComparison.Ordinal) || l.Contains("Run times", StringComparison.Ordinal)); + Assert.Equal(plain, DarlingCliCommands.FormatCollectorScheduleRows( + "index_object_stats", schedule, Array.Empty())); + + var runTimes = new[] + { + new DarlingCliCommands.CollectorRunTimeReadbackRow(null, "index_object_stats", 120, null), + new DarlingCliCommands.CollectorRunTimeReadbackRow(7, "index_object_stats", -1, "sql01"), + new DarlingCliCommands.CollectorRunTimeReadbackRow(8, "index_object_stats", 195, null), + }; + + /* A run time needs no schedule row, so the run-time lines follow the "no override rows" line as well. */ + var lines = DarlingCliCommands.FormatCollectorScheduleRows("index_object_stats", Array.Empty(), runTimes); + Assert.Contains(lines, l => l.Contains("(none", StringComparison.Ordinal)); + var from = lines.ToList().IndexOf("Run times in the store for index_object_stats:"); + Assert.True(from >= 0, "no run-time heading"); + Assert.Equal(" fleet-wide: run_at=02:00 server time", lines[from + 1]); + Assert.Equal(" sql01 (server_id 7): run_at=none (no fixed time on this server)", lines[from + 2]); + Assert.Equal(" server_id 8 (not in the servers registry): run_at=03:15 server time", lines[from + 3]); + } + + [Fact] + public void TheScheduleReadBack_NamesNoRunTime_AndTheRunTimeReadBackReadsItsOwnTable() + { + Assert.DoesNotContain("run_at_minute", DarlingCliCommands.CollectorScheduleReadbackSql, StringComparison.Ordinal); + Assert.Contains("FROM config.config_collector_run_times rt", DarlingCliCommands.CollectorRunTimeReadbackSql, StringComparison.Ordinal); + Assert.Contains("lower(rt.collector_name) = lower($1)", DarlingCliCommands.CollectorRunTimeReadbackSql, StringComparison.Ordinal); + Assert.Contains("run_at_minute", DarlingCliCommands.CollectorRunTimeReadbackSql, StringComparison.Ordinal); + foreach (var write in new[] { "INSERT", "UPDATE", "DELETE" }) + { + Assert.DoesNotContain(write, DarlingCliCommands.CollectorRunTimeReadbackSql, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void TheMissingTableMessage_NamesTheTable_AndSaysToStartTheServiceOnce() + { + Assert.Equal( + "The store has not been upgraded to the run-time table yet. Start the service once to migrate it, then run --set-collector-run-at again.", + DarlingCliCommands.RunTimeTableMissingMessage("--set-collector-run-at")); + } +} + +/// +/// --set-collector-run-at run end to end (#4938) through +/// with a bring-your-own darling.json pointed at a scratch store: parse, plan, connect, check, write, read back, +/// print. It asserts the rows the service will resolve, that the verb touches only the run time, and that a bad time or +/// a collector that runs more often than daily is refused with the shared text and a non-zero exit code. +/// +/* #1776 own-store: each fact mints its own scratch database through ScratchPostgres and never touches the shared + store's tables, so it cannot race the live collection and serializing it would be pure slowdown. */ +public sealed class CollectorRunTimeCliVerbLiveTests +{ + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private const string SkipText = "Set DARLING_TEST_PG to a Postgres connection string to run the run-time verb end to end (each fact mints its own scratch database)."; + + private const string Daily = "index_object_stats"; + private const int Sql01 = 4101; + private const int Sql02 = 4102; + + private sealed class Rig : IAsyncDisposable + { + public required ScratchPostgres Scratch { get; init; } + public required NpgsqlConnection Connection { get; init; } + public required string ConfigPath { get; init; } + public required string Directory { get; init; } + + public async ValueTask DisposeAsync() + { + await Connection.DisposeAsync(); + await Scratch.DisposeAsync(); + try { System.IO.Directory.Delete(Directory, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { } + } + } + + private static async Task OpenAsync(CancellationToken ct) + { + var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var directory = Path.Combine(Path.GetTempPath(), "darling-runat-" + Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(directory); + var configPath = Path.Combine(directory, "darling.json"); + File.WriteAllText(configPath, $$""" + { + "postgres": { + "managed": false, + "connectionString": {{JsonSerializer.Serialize(scratch.ConnectionString)}} + }, + "servers": [] + } + """); + + foreach (var (id, name) in new[] { (Sql01, "sql01"), (Sql02, "sql02") }) + { + await ExecAsync(connection, + "INSERT INTO collect.servers (server_id, server_name, display_name, is_enabled, created_date, modified_date) " + + $"VALUES ({id}, '{name}', '{name}', TRUE, now() AT TIME ZONE 'UTC', now() AT TIME ZONE 'UTC')", ct); + } + + return new Rig { Scratch = scratch, Connection = connection, ConfigPath = configPath, Directory = directory }; + } + + private static async Task<(int Exit, string Output, string Error)> RunAsync(Rig rig, CancellationToken ct, params string[] args) + { + var output = new StringWriter(); + var error = new StringWriter(); + var exit = await DarlingCliCommands.SetCollectorRunAtAsync(args.Concat(new[] { "--config", rig.ConfigPath }).ToArray(), output, error, ct); + return (exit, output.ToString(), error.ToString()); + } + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task ScalarAsync(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + return await command.ExecuteScalarAsync(ct); + } + + private static string Scope(int? serverId) => serverId is int id ? $"server_id = {id}" : "server_id IS NULL"; + + /// Every stored run time as one text: fleet/collector=minute or serverId/collector=minute. + private static async Task RunTimesAsync(NpgsqlConnection connection, CancellationToken ct) => + (string)(await ScalarAsync(connection, + "SELECT COALESCE(string_agg(COALESCE(server_id::text, 'fleet') || '/' || collector_name || '=' || run_at_minute, ',' ORDER BY server_id NULLS FIRST, collector_name), '') FROM config.config_collector_run_times", ct))!; + + private static async Task ScheduleRowsAsync(NpgsqlConnection connection, CancellationToken ct) => + Convert.ToInt64(await ScalarAsync(connection, "SELECT COUNT(*) FROM config.config_collector_schedules", ct)); + + [Fact] + public async Task TheFleetRunTime_IsWrittenToItsTable_TheNameIsCanonicalised_AndTheRowsAreReadBack() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + await using var rig = await OpenAsync(ct); + + var bodySucceeded = false; + try + { + var (exit, output, error) = await RunAsync(rig, ct, "Index_Object_Stats", "02:00"); + Assert.True(exit == 0, $"exit {exit}; stderr: {error}"); + Assert.Equal(string.Empty, error); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(rig.Connection, ct)); + Assert.Equal(0L, await ScheduleRowsAsync(rig.Connection, ct)); + Assert.Contains("Run times in the store for index_object_stats:", output, StringComparison.Ordinal); + Assert.Contains("fleet-wide: run_at=02:00 server time", output, StringComparison.Ordinal); + + /* A second time replaces the row; default and none clear it. */ + Assert.Equal(0, (await RunAsync(rig, ct, Daily, "03:30")).Exit); + Assert.Equal($"fleet/{Daily}=210", await RunTimesAsync(rig.Connection, ct)); + Assert.Equal(0, (await RunAsync(rig, ct, Daily, "default")).Exit); + Assert.Equal("", await RunTimesAsync(rig.Connection, ct)); + Assert.Equal(0, (await RunAsync(rig, ct, Daily, "none")).Exit); + Assert.Equal("", await RunTimesAsync(rig.Connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(rig.Scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task AServerRunTime_IsItsOwnRow_NoneIsMinusOne_DefaultDeletesIt_AndTheServersScheduleRowIsUntouched() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + await using var rig = await OpenAsync(ct); + + var bodySucceeded = false; + try + { + await ExecAsync(rig.Connection, + $"INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled) VALUES ({Sql01}, '{Daily}', 2880, 45, FALSE)", ct); + const string Schedule = "SELECT frequency_minutes || '|' || retention_days || '|' || enabled FROM config.config_collector_schedules WHERE server_id = " + "4101"; + + Assert.Equal(0, (await RunAsync(rig, ct, Daily, "02:00", "--server", "sql01")).Exit); + Assert.Equal($"{Sql01}/{Daily}=120", await RunTimesAsync(rig.Connection, ct)); + Assert.Equal(0, (await RunAsync(rig, ct, Daily, "01:00")).Exit); + Assert.Equal(0, (await RunAsync(rig, ct, Daily, "none", "--server", "sql01")).Exit); + Assert.Equal($"fleet/{Daily}=60,{Sql01}/{Daily}=-1", await RunTimesAsync(rig.Connection, ct)); + + Assert.Equal(0, (await RunAsync(rig, ct, Daily, "default", "--server", "sql01")).Exit); + Assert.Equal($"fleet/{Daily}=60", await RunTimesAsync(rig.Connection, ct)); + + /* The schedule row the server already had is exactly as it was: the verb never wrote it. */ + Assert.Equal("2880|45|false",(string)(await ScalarAsync(rig.Connection, Schedule, ct))!); + Assert.Equal(1L, await ScheduleRowsAsync(rig.Connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(rig.Scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task ABadTimeAnHourlyCollectorAnUnknownNameOrServer_AreRefused_WithANonZeroExit_AndNothingWritten() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + await using var rig = await OpenAsync(ct); + + var bodySucceeded = false; + try + { + await ExecAsync(rig.Connection, + $"INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes) VALUES (NULL, '{Daily}', 60)", ct); + + var bad = await RunAsync(rig, ct, Daily, "25:00"); + Assert.Equal(1, bad.Exit); + Assert.Contains(CollectorRunTime.InvalidRunAtMessage, bad.Error, StringComparison.Ordinal); + + var hourly = await RunAsync(rig, ct, Daily, "02:00"); + Assert.Equal(1, hourly.Exit); + Assert.Contains(CollectorRunTime.IntervalRefusalMessage(Daily, 60), hourly.Error, StringComparison.Ordinal); + + Assert.Equal(1, (await RunAsync(rig, ct, "no_such_collector", "02:00")).Exit); + Assert.Equal(1, (await RunAsync(rig, ct, "server_properties", "02:00", "--server", "no_such_server")).Exit); + Assert.Equal("", await RunTimesAsync(rig.Connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(rig.Scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// A store the service has not migrated to the run-time table yet (below V160). The verb says so in plain words, + /// exits 1 and writes nothing, for a time, none and default alike; and the toggle verbs' read-back reads such a store as having + /// no run times, so it does not fail after a write that committed. + [Theory] + [InlineData("02:00", null)] + [InlineData("none", "sql01")] + [InlineData("default", null)] + public async Task OnAStoreWithoutTheRunTimeTable_TheVerbRefusesWithAPlainMessage_ExitsOne_AndWritesNothing(string runAt, string? server) + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + await using var rig = await OpenAsync(ct); + + var bodySucceeded = false; + try + { + await ExecAsync(rig.Connection, "DROP TABLE config.config_collector_run_times", ct); + var scheduleRowsBefore = await ScheduleRowsAsync(rig.Connection, ct); + + var args = server is null ? new[] { Daily, runAt } : new[] { Daily, runAt, "--server", server }; + var (exit, _, error) = await RunAsync(rig, ct, args); + + Assert.Equal(1, exit); + Assert.Contains(DarlingCliCommands.RunTimeTableMissingMessage("--set-collector-run-at"), error, StringComparison.Ordinal); + Assert.Contains("Nothing was changed.", error, StringComparison.Ordinal); + Assert.DoesNotContain("relation", error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(scheduleRowsBefore, await ScheduleRowsAsync(rig.Connection, ct)); + + await using var dataSource = NpgsqlDataSource.Create(rig.Scratch.ConnectionString); + Assert.Empty(await DarlingCliCommands.ReadCollectorRunTimeRowsAsync(dataSource, Daily, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(rig.Scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } +} diff --git a/Darling/Darling.Tests/CollectorRunTimeRungTests.cs b/Darling/Darling.Tests/CollectorRunTimeRungTests.cs new file mode 100644 index 0000000000..65ca3d1c76 --- /dev/null +++ b/Darling/Darling.Tests/CollectorRunTimeRungTests.cs @@ -0,0 +1,1036 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the Darling rung that gives a collector an optional run time (#4938): config.config_collector_run_times, +/// a table of its own, because the viewer's schedule Save deletes and re-inserts the schedules table's rows with a fixed +/// column list and a column there would be cleared by every Save from a viewer that does not know it. A row holds minutes +/// after midnight on the monitored server's clock, from 0 to 1439, fleet-wide when server_id is NULL; -1 is allowed +/// on a server row only and means "no fixed time on this server", which stops a fleet-wide time. No row means no run +/// time. This file holds the rung (ladder, SQL, viewer probe, the service's read and merge) and the resolution of the +/// value, including the warning for a run time that cannot apply. Every fact finds the rung by NAME, so a renumber is one +/// edit to the registration, the constant and the viewer's return. +/// +public sealed class CollectorRunTimeRungTests +{ + public const string RungName = "collector-run-time"; + + private const string Table = "config_collector_run_times"; + + /// This rung's sentinel; the ordinal is a fact of the probe's shape. A newer rung's sentinel follows it. + private const int ProbeOrdinal = 135; + + private const int ServerId = 41; + + public static int RungVersion => Rung.Version; + + private static PgMigrations.Migration Rung => PgMigrations.Scripts.Single(m => m.Name == RungName); + + [Fact] + public void TheRungIsRegisteredInADenseLadder_AtVersion160() + { + var versions = PgMigrations.Scripts.Select(s => s.Version).ToList(); + + /* No longer the top rung: V161 (the Query Store top daily summary) landed above it. */ + Assert.Equal(160, Rung.Version); + Assert.True(Rung.Version < StorageVersion.SchemaVersion); + Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version); + Assert.Equal(versions.Distinct().OrderBy(v => v), versions); + Assert.Contains(Rung.Version - 1, versions); + } + + /// The rung creates the one table, its two partial unique indexes and the reload trigger, schema-qualified, + /// and every statement is guarded so a second run changes nothing. It names no column of the schedules table and + /// touches no row. + [Fact] + public void TheRungCreatesTheRunTimesTable_IdempotentlyAndSchemaQualified_AndTouchesNothingElse() + { + var sql = Rung.Sql.Replace("\r\n", "\n", StringComparison.Ordinal); + + Assert.Contains($"CREATE TABLE IF NOT EXISTS config.{Table} (", sql, StringComparison.Ordinal); + Assert.Contains("run_at_minute smallint NOT NULL", sql, StringComparison.Ordinal); + Assert.Contains("CHECK (run_at_minute BETWEEN 0 AND 1439 OR (run_at_minute = -1 AND server_id IS NOT NULL))", sql, StringComparison.Ordinal); + Assert.Contains( + $"CREATE UNIQUE INDEX IF NOT EXISTS ux_{Table}_fleet\n ON config.{Table} (collector_name) WHERE server_id IS NULL;", + sql, StringComparison.Ordinal); + Assert.Contains( + $"CREATE UNIQUE INDEX IF NOT EXISTS ux_{Table}_server\n ON config.{Table} (server_id, collector_name) WHERE server_id IS NOT NULL;", + sql, StringComparison.Ordinal); + Assert.Contains($"DROP TRIGGER IF EXISTS trg_bump_collector_run_times ON config.{Table};", sql, StringComparison.Ordinal); + Assert.Contains("AFTER INSERT OR UPDATE OR DELETE ON config." + Table, sql, StringComparison.Ordinal); + Assert.Contains("FOR EACH STATEMENT EXECUTE FUNCTION config.config_bump_version();", sql, StringComparison.Ordinal); + + /* The schedules table is not touched at all: no ALTER, no column of it, no row of anything. */ + Assert.DoesNotContain("config_collector_schedules", sql, StringComparison.Ordinal); + Assert.False( + Regex.IsMatch(sql, @"^\s*(ALTER TABLE|GRANT|UPDATE|DELETE FROM|INSERT INTO|TRUNCATE|DROP TABLE|DROP COLUMN)\b", RegexOptions.Multiline), + "the rung may only create the table, its indexes and its trigger"); + + /* Every CREATE of a table or index carries IF NOT EXISTS and the one DROP carries IF EXISTS. */ + foreach (Match create in Regex.Matches(sql, @"^CREATE (TABLE|UNIQUE INDEX|INDEX)\b.*$", RegexOptions.Multiline)) + { + Assert.Contains("IF NOT EXISTS", create.Value, StringComparison.Ordinal); + } + + foreach (Match drop in Regex.Matches(sql, @"^DROP\b.*$", RegexOptions.Multiline)) + { + Assert.Contains("IF EXISTS", drop.Value, StringComparison.Ordinal); + } + } + + /// The viewer's schedule statements, the ones its Save runs, never name the run time: that is what keeps a + /// released viewer's Save from touching it, and what the next viewer's Save must keep doing until it carries the time + /// on purpose. + [Fact] + public void TheViewersScheduleStatements_NeverNameTheRunTime() + { + foreach (var sql in new[] + { + ViewerDataService.CollectorSchedulesSelectSql, + ViewerDataService.CollectorScheduleFleetUpsertSql, + ViewerDataService.CollectorScheduleServerUpsertSql, + ViewerDataService.CollectorScheduleDeleteFleetScopeSql, + ViewerDataService.CollectorScheduleDeleteServerScopeSql, + ViewerDataService.CollectorScheduleDeleteAllServerScopesSql, + }) + { + Assert.DoesNotContain("run_at_minute", sql, StringComparison.Ordinal); + Assert.DoesNotContain(Table, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void TheProbeCarriesTheTableAsAnArm_AndMapsAStoreThroughTheRungToTheRung() + { + var probe = ViewerDataService.StoreSchemaProbeSql.Replace("\r\n", "\n", StringComparison.Ordinal); + var arm = $"to_regclass('config.{Table}') IS NOT NULL"; + Assert.Contains(arm, probe, StringComparison.Ordinal); + Assert.True(probe.IndexOf(arm, StringComparison.Ordinal) < probe.IndexOf("collect.query_store_top_daily", StringComparison.Ordinal), + "the newer rung's sentinel follows this one"); + + var viewer = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.cs"); + Assert.Contains($"reader.GetBoolean({ProbeOrdinal})", viewer, StringComparison.Ordinal); + + var method = typeof(ViewerDataService).GetMethod("MapProbedSchemaVersion", BindingFlags.NonPublic | BindingFlags.Static)!; + var arity = method.GetParameters().Length; + Assert.True(ProbeOrdinal < arity - 1, "a newer rung's sentinel follows this one"); + Assert.Equal("hasCollectorRunAt", method.GetParameters()[ProbeOrdinal].Name); + + var all = Enumerable.Range(0, arity).Select(i => (object)(i <= ProbeOrdinal)).ToArray(); + Assert.Equal(160, (int)method.Invoke(null, all)!); + + var behind = (object[])all.Clone(); + behind[ProbeOrdinal] = false; + Assert.Equal(159, (int)method.Invoke(null, behind)!); + + var nextArm = viewer.IndexOf("if (hasQueryStoreTopDaily)", StringComparison.Ordinal); + var thisArm = viewer.IndexOf("if (hasCollectorRunAt)", StringComparison.Ordinal); + var previousArm = viewer.IndexOf("if (hasInstallIdTableOid)", StringComparison.Ordinal); + Assert.True(thisArm >= 0, "no sentinel arm: a store at this rung would map one rung short"); + Assert.True(nextArm >= 0 && nextArm < thisArm, "the newer rung's arm sits above this one's"); + Assert.True(thisArm < previousArm, "this arm sits below the newer rung's and above the previous one's"); + Assert.Contains("return 160;", viewer[thisArm..previousArm], StringComparison.Ordinal); + + /* The V71 finding: the table is named in the probe line and nowhere in the arm's prose. The comment block sits + ABOVE the `if`, and this probe line has no information_schema in it, so the prose must not repeat the name. */ + var armProseStart = viewer.LastIndexOf("/* V160 (#4938)", thisArm, StringComparison.Ordinal); + Assert.True(armProseStart >= 0, "the arm has no comment block saying why it exists"); + Assert.DoesNotContain(Table, viewer[armProseStart..thisArm], StringComparison.Ordinal); + } + + /// The Collector Schedules window's Save is ONE call in ONE transaction (#4938): it never commits the run times as a + /// transaction of their own ahead of the schedule rows, because a schedule write that then fails would leave the run time + /// saved. The window calls the combined write once and none of the three single writes, and the combined write's scope writer + /// runs the run-time statements on the connection and transaction it opened for the schedule rows, ahead of their delete. + [Fact] + public void TheWindowsSave_IsOneCallInOneTransaction_AndNeverCommitsTheRunTimesOnItsOwn() + { + var window = CSharpSourceWalker.StripCommentsAndStrings( + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "CollectorScheduleEditorWindow.xaml.cs")); + Assert.Single(Regex.Matches(window, @"\.SaveCollectorScheduleAsync\(")); + Assert.DoesNotContain(".SaveCollectorRunTimesAsync(", window, StringComparison.Ordinal); + Assert.DoesNotContain(".ReplaceFleetSchedulesAsync(", window, StringComparison.Ordinal); + Assert.DoesNotContain(".ReplaceServerSchedulesAsync(", window, StringComparison.Ordinal); + + var viewer = CSharpSourceWalker.StripCommentsAndStrings( + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.CollectorSchedules.cs")); + var start = viewer.IndexOf("private async Task ReplaceScheduleScopeAsync(", StringComparison.Ordinal); + Assert.True(start >= 0, "the schedule scope writer is gone"); + var scope = viewer[start..viewer.IndexOf("private static void AddNullableInt", start, StringComparison.Ordinal)]; + Assert.Single(Regex.Matches(scope, @"BeginTransactionAsync\(")); + var runTimeWrite = scope.IndexOf("WriteRunTimeChangesAsync(connection, transaction, runTimeChanges", StringComparison.Ordinal); + Assert.True(runTimeWrite >= 0, "the scope writer does not write the run times on its own connection and transaction"); + Assert.True( + runTimeWrite < scope.IndexOf("CollectorScheduleDeleteFleetScopeSql", StringComparison.Ordinal), + "the run-time statements must run ahead of the schedule delete, in the same transaction"); + } + + /// A schedule Reset deletes that scope's run times in the same transaction as its schedule rows (#4938). The window's + /// "Reset to Defaults" arms the scope clear and its Save passes it on, "Apply Default to All Servers" is the one call that + /// resets both tables (and the window has no run-time reset of its own), and that call opens exactly one transaction and + /// deletes the schedule rows ahead of the run times. + [Fact] + public void AScheduleReset_DeletesTheScopesRunTimes_InTheSameTransactionAsItsScheduleRows() + { + var window = CSharpSourceWalker.StripCommentsAndStrings( + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "CollectorScheduleEditorWindow.xaml.cs")); + var reset = window[window.IndexOf("private void ResetDefaults_Click(", StringComparison.Ordinal)..window.IndexOf("private void CopyFromServer_Click(", StringComparison.Ordinal)]; + Assert.Contains("_resetToDefaults = true;", reset, StringComparison.Ordinal); + /* #4938: the Save's plan (CollectorScheduleOverlay.BuildSavePlan) carries the clear: the window hands it the armed Reset and + "Use default schedule", the plan clears on either (unless the run-time read failed), and the window sends what the plan holds. */ + Assert.Contains("usesDefault, _resetToDefaults, schedulesRead:", window, StringComparison.Ordinal); + Assert.Contains( + "var clearRunTimes = runTimesRead && (resetToDefaults || usesDefault);", + CSharpSourceWalker.StripCommentsAndStrings(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "CollectorScheduleOverlay.cs")), + StringComparison.Ordinal); + Assert.Contains(".SaveCollectorScheduleAsync(_scopeServerId, plan.Rows, plan.RunTimeChanges, plan.ClearRunTimes)", window, StringComparison.Ordinal); + Assert.Single(Regex.Matches(window, @"\.ResetAllServerSchedulesAsync\(")); + Assert.DoesNotContain("ResetAllServerRunTimes", window, StringComparison.Ordinal); + + var viewer = CSharpSourceWalker.StripCommentsAndStrings( + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "ViewerDataService.CollectorSchedules.cs")); + Assert.DoesNotContain("ResetAllServerRunTimes", viewer, StringComparison.Ordinal); + var start = viewer.IndexOf("public async Task ResetAllServerSchedulesAsync(", StringComparison.Ordinal); + Assert.True(start >= 0, "the all-servers reset is gone"); + var body = viewer[start..viewer.IndexOf("private static async Task DeleteRunTimesAsync(", start, StringComparison.Ordinal)]; + Assert.Single(Regex.Matches(body, @"BeginTransactionAsync\(")); + Assert.True( + body.IndexOf("CollectorScheduleDeleteAllServerScopesSql", StringComparison.Ordinal) + < body.IndexOf("CollectorRunTimeDeleteAllServerScopesSql", StringComparison.Ordinal), + "the schedule rows are deleted first, so a failed run-time delete takes that delete back with it"); + Assert.Contains("transaction.CommitAsync(", body, StringComparison.Ordinal); + } + + [Fact] + public void TheServiceReadsTheSchedulesWithoutARunTimeColumn_AndTheRunTimesFromTheirOwnTable() + { + var service = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "StoreConfigProvider.cs"); + + Assert.Contains( + "\"SELECT server_id, collector_name, frequency_minutes, retention_days, enabled, databases FROM config_collector_schedules\"", + service, StringComparison.Ordinal); + Assert.DoesNotContain("run_at_minute FROM config_collector_schedules", service, StringComparison.Ordinal); + + Assert.Equal( + $"SELECT server_id, collector_name, run_at_minute FROM config.{Table} ORDER BY server_id NULLS FIRST, collector_name", + StoreConfigProvider.RunTimesSelectSql); + Assert.Contains("reader.GetInt16(2)", service, StringComparison.Ordinal); + + /* A store at V159 has no table: that read, and only that one, answers 42P01 with no run times. */ + Assert.Contains("catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable)", service, StringComparison.Ordinal); + } + + /* ---- the layering (pure) --------------------------------------------------------------------------- */ + + private static ScheduleOverride Server(string collector, int? runAt, int? frequency = null) => + new(ServerId, collector, frequency, null, true, null, runAt); + + private static ScheduleOverride Fleet(string collector, int? runAt, int? frequency = null) => + new(null, collector, frequency, null, true, null, runAt); + + /// The server value wins over the fleet value; -1 on either row is "none" and on the server row stops + /// the fleet value; NULL falls through per column; a value outside the CHECK's range is treated as not set; a + /// run time on an hourly or per-minute effective interval resolves to none; a daily, a two-day and an on-load + /// collector keep theirs (the on-load collectors re-run every 1440 minutes). + [Fact] + public void ResolveSchedule_LayersTheRunTimePerColumn_AndOnlyAWholeDayIntervalKeepsIt() + { + const string Daily = "index_object_stats"; + const string OnLoad = "server_properties"; + var table = new (string Name, string Collector, ScheduleOverride[] Rows, int? Expected)[] + { + ("no rows", Daily, Array.Empty(), null), + ("fleet value", Daily, new[] { Fleet(Daily, 120) }, 120), + ("server value", Daily, new[] { Server(Daily, 130) }, 130), + ("server wins over fleet", Daily, new[] { Server(Daily, 130), Fleet(Daily, 120) }, 130), + ("server -1 stops the fleet value", Daily, new[] { Server(Daily, -1), Fleet(Daily, 120) }, null), + ("fleet -1 is none", Daily, new[] { Fleet(Daily, -1) }, null), + ("server NULL falls through", Daily, new[] { Server(Daily, null), Fleet(Daily, 120) }, 120), + ("both NULL", Daily, new[] { Server(Daily, null), Fleet(Daily, null) }, null), + ("midnight is a time", Daily, new[] { Fleet(Daily, 0) }, 0), + ("last minute of the day", Daily, new[] { Fleet(Daily, 1439) }, 1439), + ("server value out of range falls through", Daily, new[] { Server(Daily, 1440), Fleet(Daily, 120) }, 120), + ("below -1 is not set", Daily, new[] { Server(Daily, -2) }, null), + ("hourly default has none", "pg_buffer_usage", new[] { Fleet("pg_buffer_usage", 120) }, null), + ("per-minute default has none", "wait_stats", new[] { Fleet("wait_stats", 120), Server("wait_stats", 90) }, null), + ("server frequency override makes it hourly", Daily, new[] { Server(Daily, null, 60), Fleet(Daily, 120) }, null), + ("two-day interval keeps it", Daily, new[] { Server(Daily, 90, 2880) }, 90), + ("on-load collector keeps it", OnLoad, new[] { Fleet(OnLoad, 150) }, 150), + ("explicit on-load frequency keeps it", OnLoad, new[] { Server(OnLoad, 150, 0) }, 150), + ("a row for another collector is not read", Daily, new[] { Fleet(OnLoad, 150) }, null), + }; + + foreach (var (name, collector, rows, expected) in table) + { + var resolved = StoreConfigProvider.ResolveSchedule(collector, ServerId, rows); + Assert.True(expected == resolved.RunAtMinute, $"{name}: expected {expected?.ToString() ?? "none"} but resolved {resolved.RunAtMinute?.ToString() ?? "none"}"); + } + } + + /// A dropped run time leaves the cadence alone: the frequency is what the override says, as it was + /// before the column existed. + [Fact] + public void ADroppedRunTime_DoesNotChangeTheFrequency() + { + var resolved = StoreConfigProvider.ResolveSchedule("wait_stats", ServerId, new[] { Server("wait_stats", 90, 15) }); + + Assert.Null(resolved.RunAtMinute); + Assert.Equal(15, resolved.FrequencyMinutes); + } + + /* ---- the warning (once per load, judged per server) -------------------------------------------------- */ + + private static MonitoredServer Target(int id, string name, string engine = "sqlserver") => + new() { Name = name, Host = name + "-host", Engine = engine, StoredServerId = id }; + + [Fact] + public void AFleetRunTime_IsJudgedPerServer_SoOnlyAServerWithItsOwnHourlyFrequencyIsNamed() + { + var servers = new[] { Target(41, "alpha"), Target(42, "bravo") }; + var overrides = new[] + { + Fleet("index_object_stats", 120), + new ScheduleOverride(42, "index_object_stats", 60, null, true), + }; + + var dropped = Assert.Single(StoreConfigProvider.FindDroppedRunTimes(servers, overrides)); + + Assert.Equal("index_object_stats", dropped.CollectorName); + Assert.Equal(42, dropped.ServerId); + Assert.Equal("bravo", dropped.ServerName); + Assert.Equal(120, dropped.RunAtMinute); + Assert.Equal(60, dropped.IntervalMinutes); + Assert.False(dropped.FromServerRow); + } + + [Fact] + public void AServerRowRunTimeOnAnHourlyCollector_IsNamedWithItsRow_AndAnEngineThatNeverRunsItIsSkipped() + { + var servers = new[] { Target(41, "alpha"), Target(43, "pgone", "postgres") }; + + var serverRow = Assert.Single(StoreConfigProvider.FindDroppedRunTimes(servers, new[] { Server("wait_stats", 120) })); + Assert.Equal(41, serverRow.ServerId); + Assert.True(serverRow.FromServerRow); + + /* A fleet row for a PostgreSQL-only collector names the PostgreSQL server, not the SQL Server one that + never runs it. */ + var fleetRow = Assert.Single(StoreConfigProvider.FindDroppedRunTimes(servers, new[] { Fleet("pg_buffer_usage", 120) })); + Assert.Equal(43, fleetRow.ServerId); + } + + [Fact] + public void NothingIsDropped_ForANoneValue_ADailyCollector_OrAnOnLoadCollector() + { + var servers = new[] { Target(41, "alpha") }; + var overrides = new[] + { + Server("wait_stats", -1), + Fleet("index_object_stats", 120), + Fleet("server_properties", 150), + Fleet("wait_stats", null), + }; + + Assert.Empty(StoreConfigProvider.FindDroppedRunTimes(servers, overrides)); + } + + /// One warning per dropped run time, naming the collector, the server and the time; none when nothing is + /// dropped. It logs at the load, not in ResolveSchedule, which runs every sweep and stays silent. + [Fact] + public void TheWarning_NamesTheCollectorAndTheServer_AndResolveScheduleItselfLogsNothing() + { + var servers = new[] { Target(41, "alpha") }; + var overrides = new[] { Server("wait_stats", 120) }; + var logger = new CapturingTestLogger(); + + StoreConfigProvider.LogDroppedRunTimes(logger, servers, overrides); + + var line = Assert.Single(logger.Lines); + Assert.Equal(1, logger.CountAtLevel(LogLevel.Warning)); + Assert.Contains("wait_stats", line, StringComparison.Ordinal); + Assert.Contains("alpha", line, StringComparison.Ordinal); + Assert.Contains("02:00", line, StringComparison.Ordinal); + + for (var sweep = 0; sweep < 3; sweep++) + { + _ = StoreConfigProvider.ResolveSchedule("wait_stats", 41, overrides); + } + + Assert.Single(logger.Lines); + + var quiet = new CapturingTestLogger(); + StoreConfigProvider.LogDroppedRunTimes(quiet, servers, new[] { Server("wait_stats", -1) }); + Assert.Empty(quiet.Lines); + StoreConfigProvider.LogDroppedRunTimes(null, servers, overrides); + } + + /// A stored value the table's CHECK would refuse (a store whose CHECK was dropped by hand) resolves to none, + /// and the load says so: one warning for each, naming the collector, the scope and the value. A value the CHECK allows + /// (0, 1439, and -1 on a server) is never reported, and a sweep that only resolves logs nothing. + [Fact] + public void TheWarning_NamesAValueTheTableWouldRefuse_WithTheCollectorTheScopeAndTheValue() + { + var servers = new[] { Target(41, "alpha") }; + var refused = new[] + { + Fleet("index_object_stats", 1440), + Server("server_properties", -2), + Fleet("server_properties", 0), + Server("index_object_stats", 1439), + }; + var logger = new CapturingTestLogger(); + + StoreConfigProvider.LogDroppedRunTimes(logger, servers, refused); + + Assert.Equal(2, logger.CountAtLevel(LogLevel.Warning)); + var fleet = Assert.Single(logger.Lines, l => l.Contains("index_object_stats", StringComparison.Ordinal)); + Assert.Contains("fleet-wide", fleet, StringComparison.Ordinal); + Assert.Contains("1440", fleet, StringComparison.Ordinal); + var server = Assert.Single(logger.Lines, l => l.Contains("server_properties", StringComparison.Ordinal)); + Assert.Contains("server id 41", server, StringComparison.Ordinal); + Assert.Contains("-2", server, StringComparison.Ordinal); + + /* A sweep that only resolves logs nothing more. */ + for (var sweep = 0; sweep < 3; sweep++) + { + _ = StoreConfigProvider.ResolveSchedule("server_properties", ServerId, refused); + } + + Assert.Equal(2, logger.Lines.Count); + + var quiet = new CapturingTestLogger(); + StoreConfigProvider.LogDroppedRunTimes(quiet, servers, new[] + { + Fleet("index_object_stats", 0), + Server("index_object_stats", -1), + Fleet("server_properties", 1439), + }); + Assert.Empty(quiet.Lines); + } + + /* ---- the merge of the run-time rows onto the schedule rows (pure) ------------------------------------- */ + + private static RunTimeOverride RunTime(int? serverId, string collector, int minute) => new(serverId, collector, minute); + + [Fact] + public void MergeRunTimes_CarriesARunTimeOnItsScheduleRow_AndKeepsEveryOtherColumnOfTheRow() + { + var scope = new[] { "alpha_db" }; + var fleetRow = new ScheduleOverride(null, "index_object_stats", 1440, 30, false, scope); + var serverRow = new ScheduleOverride(ServerId, "index_object_stats", 2880, null, true); + + var merged = StoreConfigProvider.MergeRunTimes( + new[] { fleetRow, serverRow }, + new[] { RunTime(null, "index_object_stats", 120), RunTime(ServerId, "index_object_stats", -1) }); + + Assert.Equal(2, merged.Count); + Assert.Equal(fleetRow with { RunAtMinute = 120 }, Assert.Single(merged, o => o.ServerId is null)); + Assert.Equal(serverRow with { RunAtMinute = -1 }, Assert.Single(merged, o => o.ServerId == ServerId)); + } + + /// A run time with no schedule row makes an entry that sets nothing else. In particular its enabled state is + /// not set, so it can neither switch on a collector that is off by default nor switch off one the fleet row enabled, + /// and every other column resolves exactly as it did without the entry. + [Fact] + public void MergeRunTimes_GivesARunTimeWithNoScheduleRowAnEntryThatSetsNothingElse() + { + var merged = StoreConfigProvider.MergeRunTimes( + Array.Empty(), + new[] { RunTime(null, "index_object_stats", 120), RunTime(ServerId, "server_properties", 150) }); + + Assert.Equal(2, merged.Count); + foreach (var entry in merged) + { + Assert.Null(entry.FrequencyMinutes); + Assert.Null(entry.RetentionDays); + Assert.Null(entry.Enabled); + Assert.Null(entry.Databases); + } + + Assert.Equal(120, StoreConfigProvider.ResolveSchedule("index_object_stats", ServerId, merged).RunAtMinute); + Assert.Equal(150, StoreConfigProvider.ResolveSchedule("server_properties", ServerId, merged).RunAtMinute); + Assert.Null(StoreConfigProvider.ResolveSchedule("server_properties", ServerId + 1, merged).RunAtMinute); + } + + [Fact] + public void ARunTimeEntry_NeverChangesEnabledFrequencyRetentionOrScope_AtAnyLevel() + { + var rows = new[] + { + new ScheduleOverride(null, "index_object_stats", 1440, 7, false, new[] { "alpha_db" }), + new ScheduleOverride(null, "pg_index_usage_stats", null, 9, true), + }; + var runTimes = new[] + { + RunTime(ServerId, "index_object_stats", 130), + RunTime(null, "long_query_completions", 100), + RunTime(ServerId, "long_query_completions", 110), + RunTime(null, "pg_index_usage_stats", 140), + RunTime(null, "wait_stats", 150), + }; + var merged = StoreConfigProvider.MergeRunTimes(rows, runTimes); + + foreach (var collector in new[] { "index_object_stats", "pg_index_usage_stats", "long_query_completions", "wait_stats", "server_properties" }) + { + var before = StoreConfigProvider.ResolveSchedule(collector, ServerId, rows); + var after = StoreConfigProvider.ResolveSchedule(collector, ServerId, merged); + Assert.Equal(before.Enabled, after.Enabled); + Assert.Equal(before.FrequencyMinutes, after.FrequencyMinutes); + Assert.Equal(before.RetentionDays, after.RetentionDays); + Assert.Equal(StoreConfigProvider.ResolveFleetRetentionDays(collector, rows), StoreConfigProvider.ResolveFleetRetentionDays(collector, merged)); + Assert.Equal(StoreConfigProvider.ResolveDatabaseScope(collector, ServerId, rows), StoreConfigProvider.ResolveDatabaseScope(collector, ServerId, merged)); + } + + /* The fleet row disabled index_object_stats and the server's run time must not re-enable it for that server, and + a collector off by default stays off. */ + Assert.False(StoreConfigProvider.ResolveSchedule("index_object_stats", ServerId, merged).Enabled); + Assert.False(StoreConfigProvider.ResolveSchedule("long_query_completions", ServerId, merged).Enabled); + Assert.Equal(130, StoreConfigProvider.ResolveSchedule("index_object_stats", ServerId, merged).RunAtMinute); + } + + [Fact] + public void MergeRunTimes_HoldsOneEntryPerScopeAndCollector_MatchingTheNameWithoutRegardToCase() + { + var merged = StoreConfigProvider.MergeRunTimes( + new[] { new ScheduleOverride(null, "Index_Object_Stats", 1440, 30, true) }, + new[] { RunTime(null, "index_object_stats", 120), RunTime(ServerId, "index_object_stats", 130), RunTime(ServerId + 1, "index_object_stats", 140) }); + + Assert.Equal(3, merged.Count); + Assert.Equal(1, merged.Count(o => o.ServerId is null)); + Assert.Equal(120, merged.Single(o => o.ServerId is null).RunAtMinute); + Assert.Equal(30, merged.Single(o => o.ServerId is null).RetentionDays); + + /* The fleet row's retention is still the first, and only, fleet entry the retention horizon reads. */ + Assert.Equal(30, StoreConfigProvider.ResolveFleetRetentionDays("index_object_stats", merged)); + } + + [Fact] + public void MergeRunTimes_WithNoRunTimes_ReturnsTheRowsAsTheyAre() + { + var rows = new[] { new ScheduleOverride(null, "index_object_stats", 1440, 30, true) }; + + Assert.Same(rows, StoreConfigProvider.MergeRunTimes(rows, Array.Empty())); + } +} + +/// The live half of : the table, its CHECK, indexes and reload trigger, an +/// upgrade from V159, the rung's SQL run twice on one store, what the viewer's schedule Save leaves alone, and the +/// service's read (merged onto the schedule rows, warned once per load, and tolerant of a store without the table). +/* #1776 own-store: each fact mints its own scratch database through ScratchPostgres and never touches the shared + store's tables, so it cannot race the live collection and serializing it would be pure slowdown. */ +public sealed class CollectorRunTimeRungLiveTests +{ + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private const string SkipText = "Set DARLING_TEST_PG to a Postgres connection string to run the run-time table's live pins (each mints its own scratch database)."; + + private const string Insert = "INSERT INTO config.config_collector_run_times (server_id, collector_name, run_at_minute) VALUES "; + + private static async Task MigratedAsync(ScratchPostgres scratch, System.Threading.CancellationToken ct) + { + var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + return connection; + } + + private static async Task ScalarAsync(NpgsqlConnection connection, string sql, System.Threading.CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + return await command.ExecuteScalarAsync(ct); + } + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, System.Threading.CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(ct); + } + + private static async Task VersionAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) => + Convert.ToInt32(await ScalarAsync(connection, "SELECT MAX(version) FROM darling_schema_version", ct)); + + /// Every run-time row as one string, so a test can say "unchanged" in one assertion. + private static async Task RowsAsync(NpgsqlConnection connection, System.Threading.CancellationToken ct) => + (string?)await ScalarAsync(connection, + "SELECT COALESCE(string_agg(COALESCE(server_id::text, 'fleet') || '/' || collector_name || '=' || run_at_minute, ',' ORDER BY server_id NULLS FIRST, collector_name), '') FROM config.config_collector_run_times", ct); + + private static async Task SeededProviderAsync( + NpgsqlDataSource dataSource, CapturingTestLogger logger, System.Threading.CancellationToken ct, + string storedPassword = "env:RUNTIME_ALPHA_PASSWORD_NOT_SET") + { + var provider = new StoreConfigProvider(dataSource, logger); + var config = new DarlingConfig(); + config.Servers.Add(new MonitoredServer + { + Name = "runtime-alpha", + Host = "runtime-alpha-host", + Auth = "sql", + Username = "runtime-user", + /* A reference by default, not a stored old-format password: the load warns that an old-format password needs to be entered + again (#5366), and the run-time tests count the warnings that belong to the run times. */ + EncryptedPassword = storedPassword, + EncryptMode = "Strict", + TrustServerCertificate = true, + }); + await provider.SeedIfEmptyAsync(config, ct); + return provider; + } + + [Fact] + public async Task TheTable_HasItsColumnsItsCheckItsTwoUniqueIndexesAndTheReloadTrigger() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + Assert.Equal("server_id:integer:YES,collector_name:text:NO,run_at_minute:smallint:NO", await ScalarAsync(connection, + "SELECT string_agg(column_name || ':' || data_type || ':' || is_nullable, ',' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema = 'config' AND table_name = 'config_collector_run_times'", ct)); + Assert.Equal(0L, await ScalarAsync(connection, + "SELECT count(*) FROM information_schema.columns WHERE table_schema = 'config' AND table_name = 'config_collector_schedules' AND column_name = 'run_at_minute'", ct)); + + /* The CHECK: 0 to 1439 anywhere, -1 on a server row only. */ + foreach (var refused in new[] { "NULL, 'rt_bad', -1", "NULL, 'rt_bad', -2", "NULL, 'rt_bad', 1440", "7, 'rt_bad', -2", "7, 'rt_bad', 1440" }) + { + var ex = await Assert.ThrowsAsync(() => ExecAsync(connection, Insert + $"({refused})", ct)); + Assert.Equal("23514", ex.SqlState); + } + + var notNull = await Assert.ThrowsAsync(() => ExecAsync(connection, Insert + "(NULL, 'rt_null', NULL)", ct)); + Assert.Equal("23502", notNull.SqlState); + + foreach (var taken in new[] { "NULL, 'rt_a', 0", "NULL, 'rt_b', 1439", "7, 'rt_c', -1", "7, 'rt_d', 0" }) + { + await ExecAsync(connection, Insert + $"({taken})", ct); + } + + /* One fleet row and one server row per collector; the same collector on another server, or fleet-wide and on + a server, is fine. */ + foreach (var duplicate in new[] { "NULL, 'rt_a', 5", "7, 'rt_d', 9" }) + { + var ex = await Assert.ThrowsAsync(() => ExecAsync(connection, Insert + $"({duplicate})", ct)); + Assert.Equal("23505", ex.SqlState); + } + + await ExecAsync(connection, Insert + "(8, 'rt_d', 9), (7, 'rt_a', 9)", ct); + Assert.Contains("(collector_name) WHERE (server_id IS NULL)", (string?)await ScalarAsync(connection, + "SELECT indexdef FROM pg_indexes WHERE schemaname = 'config' AND indexname = 'ux_config_collector_run_times_fleet'", ct), StringComparison.Ordinal); + Assert.Contains("(server_id, collector_name) WHERE (server_id IS NOT NULL)", (string?)await ScalarAsync(connection, + "SELECT indexdef FROM pg_indexes WHERE schemaname = 'config' AND indexname = 'ux_config_collector_run_times_server'", ct), StringComparison.Ordinal); + + /* A write bumps the reload beacon, as a schedule write does, so a running service reloads the run times. */ + Assert.Equal(1L, await ScalarAsync(connection, + "SELECT count(*) FROM pg_trigger WHERE tgname = 'trg_bump_collector_run_times' AND NOT tgisinternal", ct)); + await ExecAsync(connection, "INSERT INTO config.config_service (id) VALUES (1) ON CONFLICT (id) DO NOTHING", ct); + foreach (var write in new[] + { + Insert + "(NULL, 'rt_e', 10)", + "UPDATE config.config_collector_run_times SET run_at_minute = 11 WHERE collector_name = 'rt_e'", + "DELETE FROM config.config_collector_run_times WHERE collector_name = 'rt_e'", + }) + { + var before = Convert.ToInt64(await ScalarAsync(connection, "SELECT config_version FROM config.config_service", ct)); + await ExecAsync(connection, write, ct); + Assert.True(Convert.ToInt64(await ScalarAsync(connection, "SELECT config_version FROM config.config_service", ct)) > before, write); + } + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task AStoreAtV159_UpgradesToTheRung_WithNoRunTimes_AndItsScheduleRowsAndColumnsUntouched() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + const string ScheduleColumns = + "SELECT string_agg(column_name, ',' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema = 'config' AND table_name = 'config_collector_schedules'"; + var columnsBefore = await ScalarAsync(connection, ScheduleColumns, ct); + + /* Put the store back at V159: no table, its stamp gone, a schedule row an operator already wrote. */ + foreach (var sql in new[] + { + "DROP TABLE config.config_collector_run_times", + "DELETE FROM darling_schema_version WHERE version >= 160", + "INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days, enabled) VALUES (NULL, 'index_object_stats', 1440, 30, TRUE)", + }) + { + await ExecAsync(connection, sql, ct); + } + + Assert.Equal(159, await VersionAsync(connection, ct)); + Assert.True(await ScalarAsync(connection, "SELECT to_regclass('config.config_collector_run_times') IS NULL", ct) is true); + + await PgMigrations.MigrateAsync(connection, ct); + + Assert.Equal(StorageVersion.SchemaVersion, await VersionAsync(connection, ct)); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM config.config_collector_run_times", ct)); + Assert.Equal("1440|30", await ScalarAsync(connection, + "SELECT frequency_minutes || '|' || retention_days FROM config.config_collector_schedules WHERE collector_name = 'index_object_stats'", ct)); + Assert.Equal(columnsBefore, await ScalarAsync(connection, ScheduleColumns, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// The runner skips a rung a store is already stamped at, so migrating twice never runs the rung's SQL + /// twice. This runs the rung's own SQL twice on one store that holds rows and asserts the second run is a no-op: + /// no error, the same columns, indexes, constraint and trigger, and the same rows. + [Fact] + public async Task TheRungSql_RunTwiceOnOneStore_ChangesNothing_AndKeepsItsRows() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + var rungSql = PgMigrations.Scripts.Single(m => m.Name == CollectorRunTimeRungTests.RungName).Sql; + await ExecAsync(connection, Insert + "(NULL, 'index_object_stats', 120), (7, 'index_object_stats', -1)", ct); + + const string Shape = + "SELECT (SELECT string_agg(column_name || ':' || data_type || ':' || is_nullable, ',' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema = 'config' AND table_name = 'config_collector_run_times')" + + " || '|' || (SELECT string_agg(indexname || ':' || indexdef, ';' ORDER BY indexname) FROM pg_indexes WHERE schemaname = 'config' AND tablename = 'config_collector_run_times')" + + " || '|' || (SELECT string_agg(conname || ':' || pg_get_constraintdef(oid), ';' ORDER BY conname) FROM pg_constraint WHERE conrelid = 'config.config_collector_run_times'::regclass)" + + " || '|' || (SELECT count(*) FROM pg_trigger WHERE tgrelid = 'config.config_collector_run_times'::regclass AND NOT tgisinternal)"; + var shapeBefore = await ScalarAsync(connection, Shape, ct); + var rowsBefore = await RowsAsync(connection, ct); + Assert.Equal("fleet/index_object_stats=120,7/index_object_stats=-1", rowsBefore); + + await ExecAsync(connection, rungSql, ct); + await ExecAsync(connection, rungSql, ct); + + Assert.Equal(shapeBefore, await ScalarAsync(connection, Shape, ct)); + Assert.Equal(rowsBefore, await RowsAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// The install id's row survives the upgrade from V159 untouched (#4938, #4961). V159 is the install id table's + /// final shape, and this rung creates a table of its own and restates nothing of it: the id, its bindings and its + /// creation time read the same after the rung as before it, and the table keeps exactly its seven columns. + [Fact] + public async Task AStoreAtV159_KeepsItsInstallIdRowAndTableShapeUnchanged_AcrossTheUpgradeToTheRung() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + const string ReadRow = + "SELECT install_id || '|' || system_identifier || '|' || database_oid || '|' || table_oid || '|' || server_major || '|' || created_at::text FROM config.config_install_id"; + const string ReadShape = + "SELECT string_agg(column_name || ':' || data_type, ',' ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema = 'config' AND table_name = 'config_install_id'"; + const string Row = "0a1b2c3d|7000000000000000001|16384|16400|17|2026-01-02 03:04:05.678901"; + const string Shape = "id:smallint,install_id:text,system_identifier:bigint,database_oid:bigint,created_at:timestamp without time zone,table_oid:bigint,server_major:integer"; + + Assert.Equal(StorageVersion.SchemaVersion, await VersionAsync(connection, ct)); + Assert.Equal(Shape, await ScalarAsync(connection, ReadShape, ct)); + + /* Put the store back at V159: the run time's table and stamp gone, an install id row the service already made. */ + foreach (var sql in new[] + { + "DROP TABLE config.config_collector_run_times", + "DELETE FROM darling_schema_version WHERE version >= 160", + "INSERT INTO config.config_install_id (install_id, system_identifier, database_oid, table_oid, server_major, created_at) VALUES ('0a1b2c3d', 7000000000000000001, 16384, 16400, 17, '2026-01-02 03:04:05.678901')", + }) + { + await ExecAsync(connection, sql, ct); + } + + Assert.Equal(159, await VersionAsync(connection, ct)); + Assert.Equal(Row, await ScalarAsync(connection, ReadRow, ct)); + Assert.Equal(Shape, await ScalarAsync(connection, ReadShape, ct)); + + await PgMigrations.MigrateAsync(connection, ct); + + Assert.Equal(StorageVersion.SchemaVersion, await VersionAsync(connection, ct)); + Assert.True(await ScalarAsync(connection, "SELECT to_regclass('config.config_collector_run_times') IS NOT NULL", ct) is true); + Assert.Equal(Row, await ScalarAsync(connection, ReadRow, ct)); + Assert.Equal(Shape, await ScalarAsync(connection, ReadShape, ct)); + Assert.Equal(1L, await ScalarAsync(connection, "SELECT COUNT(*) FROM config.config_install_id", ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// The viewer's schedule Save, run live (its real SQL: delete the scope's rows, insert them again), leaves every + /// run-time row alone, for a fleet scope and for a server scope. A viewer released before the table existed does the + /// same statements, so the run times survive its Save too. The Save itself still works. + [Fact] + public async Task TheViewerScheduleSave_LeavesEveryRunTimeRowAlone_ForAFleetScopeAndAServerScope() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + await ExecAsync(connection, + Insert + "(NULL, 'index_object_stats', 120), (NULL, 'server_properties', 150), (7, 'index_object_stats', 130), (7, 'server_properties', -1), (8, 'index_object_stats', 200)", ct); + await ExecAsync(connection, + "INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes) VALUES (NULL, 'index_object_stats', 720), (7, 'index_object_stats', 2880)", ct); + var expected = await RowsAsync(connection, ct); + Assert.Equal("fleet/index_object_stats=120,fleet/server_properties=150,7/index_object_stats=130,7/server_properties=-1,8/index_object_stats=200", expected); + + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + await viewer.ReplaceFleetSchedulesAsync(new[] { new CollectorScheduleRow(null, "index_object_stats", 1440, 30, true, null) }, ct); + Assert.Equal(1440, Convert.ToInt32(await ScalarAsync(connection, + "SELECT frequency_minutes FROM config.config_collector_schedules WHERE server_id IS NULL AND collector_name = 'index_object_stats'", ct))); + Assert.Equal(expected, await RowsAsync(connection, ct)); + + await viewer.ReplaceServerSchedulesAsync(7, new[] { new CollectorScheduleRow(7, "index_object_stats", 1440, null, true, null) }, ct); + Assert.Equal(1440, Convert.ToInt32(await ScalarAsync(connection, + "SELECT frequency_minutes FROM config.config_collector_schedules WHERE server_id = 7 AND collector_name = 'index_object_stats'", ct))); + Assert.Equal(expected, await RowsAsync(connection, ct)); + + /* A Save that leaves a scope empty deletes schedule rows only. The reset of every server's rows is the one call that also + deletes the servers' run times (#4938), and the fleet's stay. */ + await viewer.ReplaceServerSchedulesAsync(7, Array.Empty(), ct); + Assert.Equal(expected, await RowsAsync(connection, ct)); + await viewer.ResetAllServerSchedulesAsync(ct); + Assert.Equal(0L, await ScalarAsync(connection, "SELECT count(*) FROM config.config_collector_schedules WHERE server_id IS NOT NULL", ct)); + Assert.Equal("fleet/index_object_stats=120,fleet/server_properties=150", await RowsAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// The service's read merges the run times onto the schedule rows, and the load warns once for a run time that + /// cannot apply, each time the schedules are loaded. + [Fact] + public async Task LoadViewAsync_ReadsTheRunTimesFromTheirTable_AndWarnsOncePerLoad_ForAServerWhoseIntervalIsHourly() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + var logger = new CapturingTestLogger(); + var provider = await SeededProviderAsync(dataSource, logger, ct); + + var serverId = Convert.ToInt32(await ScalarAsync(connection, "SELECT server_id FROM config_monitored_servers WHERE name = 'runtime-alpha'", ct)); + foreach (var sql in new[] + { + Insert + "(NULL, 'index_object_stats', 120)", + $"INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes) VALUES ({serverId}, 'index_object_stats', 60)", + Insert + "(NULL, 'server_properties', 150)", + "INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days) VALUES (NULL, 'pg_index_usage_stats', 1440, 30)", + Insert + "(NULL, 'pg_index_usage_stats', 200)", + }) + { + await ExecAsync(connection, sql, ct); + } + + var first = await provider.LoadViewAsync(new DarlingConfig(), ct); + Assert.NotNull(first); + Assert.Equal(120, first!.ScheduleOverrides.Single(o => o.ServerId is null && o.CollectorName == "index_object_stats").RunAtMinute); + Assert.Null(first.ScheduleOverrides.Single(o => o.ServerId == serverId).RunAtMinute); + + /* A run time for a collector that has a schedule row rides on that row. */ + var carried = first.ScheduleOverrides.Single(o => o.ServerId is null && o.CollectorName == "pg_index_usage_stats"); + Assert.Equal(30, carried.RetentionDays); + Assert.Equal(200, carried.RunAtMinute); + + Assert.Equal(150, StoreConfigProvider.ResolveSchedule("server_properties", serverId, first.ScheduleOverrides).RunAtMinute); + Assert.Null(StoreConfigProvider.ResolveSchedule("index_object_stats", serverId, first.ScheduleOverrides).RunAtMinute); + + Assert.Single(logger.Lines, l => l.Contains("run time", StringComparison.OrdinalIgnoreCase)); + + /* Each load warns again; a sweep between loads, which only resolves, does not. */ + _ = StoreConfigProvider.ResolveSchedule("index_object_stats", serverId, first.ScheduleOverrides); + Assert.Single(logger.Lines, l => l.Contains("run time", StringComparison.OrdinalIgnoreCase)); + await provider.LoadViewAsync(new DarlingConfig(), ct); + Assert.Equal(2, logger.Lines.Count(l => l.Contains("run time", StringComparison.OrdinalIgnoreCase))); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// A store still at V159 has no run-time table. The load reads it as no run times, with no warning and no error + /// (one debug line at most), keeps every schedule row, and leaves the connection usable for the next load. + [Fact] + public async Task LoadViewAsync_OnAStoreWithoutTheRunTimeTable_ReadsNoRunTimes_WithNoWarningAndNoError() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + var logger = new CapturingTestLogger(); + var provider = await SeededProviderAsync(dataSource, logger, ct); + + await ExecAsync(connection, "DROP TABLE config.config_collector_run_times", ct); + await ExecAsync(connection, + "INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes, retention_days) VALUES (NULL, 'index_object_stats', 1440, 30)", ct); + + var view = await provider.LoadViewAsync(new DarlingConfig(), ct); + + Assert.NotNull(view); + var row = Assert.Single(view!.ScheduleOverrides); + Assert.Equal(30, row.RetentionDays); + Assert.Null(row.RunAtMinute); + Assert.Equal(0, logger.CountAtLevel(LogLevel.Warning)); + Assert.Equal(0, logger.CountAtLevel(LogLevel.Error)); + Assert.True(logger.Lines.Count(l => l.Contains("run-time table", StringComparison.OrdinalIgnoreCase)) <= 1, logger.Joined); + + Assert.NotNull(await provider.LoadViewAsync(new DarlingConfig(), ct)); + Assert.Equal(0, logger.CountAtLevel(LogLevel.Warning)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// A store whose CHECK was dropped by hand can hold a value the CHECK would refuse. The load resolves it to none + /// and warns once, naming the collector, the scope and the value. + [Fact] + public async Task LoadViewAsync_WarnsOnce_ForAStoredRunTimeTheCheckWouldRefuse_AndResolvesItToNone() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + var logger = new CapturingTestLogger(); + var provider = await SeededProviderAsync(dataSource, logger, ct); + + await ExecAsync(connection, "ALTER TABLE config.config_collector_run_times DROP CONSTRAINT ck_config_collector_run_times_range", ct); + await ExecAsync(connection, Insert + "(NULL, 'index_object_stats', 1440)", ct); + + var view = await provider.LoadViewAsync(new DarlingConfig(), ct); + + Assert.NotNull(view); + Assert.Null(StoreConfigProvider.ResolveSchedule("index_object_stats", 1, view!.ScheduleOverrides).RunAtMinute); + Assert.Equal(1, logger.CountAtLevel(LogLevel.Warning)); + var line = Assert.Single(logger.Lines, l => l.StartsWith("Warning", StringComparison.Ordinal)); + Assert.Contains("index_object_stats", line, StringComparison.Ordinal); + Assert.Contains("fleet-wide", line, StringComparison.Ordinal); + Assert.Contains("1440", line, StringComparison.Ordinal); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// A stored old-format password the service cannot open is reported by the load as one warning, and only once: the + /// next load of the same store, with the same count, says nothing more (#5366). + [Fact] + public async Task LoadViewAsync_WarnsOnce_AboutAnOldFormatPasswordItCannotOpen_NotOnEveryReload() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = await MigratedAsync(scratch, ct); + + var bodySucceeded = false; + try + { + await using var dataSource = NpgsqlDataSource.Create(scratch.ConnectionString); + var logger = new CapturingTestLogger(); + var provider = await SeededProviderAsync(dataSource, logger, ct, storedPassword: "not-a-real-blob"); + + Assert.NotNull(await provider.LoadViewAsync(new DarlingConfig(), ct)); + var line = Assert.Single(logger.Lines, l => l.StartsWith("Warning", StringComparison.Ordinal)); + Assert.Contains("needs to be entered again", line, StringComparison.Ordinal); + + Assert.NotNull(await provider.LoadViewAsync(new DarlingConfig(), ct)); + Assert.Equal(1, logger.CountAtLevel(LogLevel.Warning)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } +} diff --git a/Darling/Darling.Tests/CollectorRunTimeTests.cs b/Darling/Darling.Tests/CollectorRunTimeTests.cs new file mode 100644 index 0000000000..5c6a508fae --- /dev/null +++ b/Darling/Darling.Tests/CollectorRunTimeTests.cs @@ -0,0 +1,610 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Collectors; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4938: the shared rules for a daily collector's run time, . The run time is the +/// monitored server's own wall clock, so each test hands the rules a local-to-UTC conversion the way an app +/// does: for a known clock, while the +/// clock is not known yet. US Eastern springs forward on 2026-03-08 and falls back on 2026-11-01. A server id +/// whose (uint)id % 3600 is 0 puts the slot exactly on the run time; 1800 adds 30 minutes and 3599 adds +/// 59 minutes 59 seconds. +/// +public sealed class CollectorRunTimeTests +{ + private const int NoSpreadId = 0; + private const int HalfHourSpreadId = 1800; + private const int LongSpreadId = 3599; + private const int TwoAm = 120; + private const int Daily = 1440; + private const string EasternWindowsId = "Eastern Standard Time"; + private const string EasternIanaId = "America/New_York"; + + private static DateTime Utc(int year, int month, int day, int hour = 0, int minute = 0, int second = 0) => + new(year, month, day, hour, minute, second, DateTimeKind.Utc); + + private static DateTime NextDue(DateTime now, DateTime? lastRun, int runAt, int interval, int serverId, + Func toUtc) => + CollectorRunTime.NextDue(now, lastRun, runAt, interval, serverId, toUtc); + + private static DateTime UtcClockNextDue(DateTime now, DateTime? lastRun, int runAt = TwoAm, int interval = Daily, + int serverId = NoSpreadId) => + CollectorRunTime.NextDue(now, lastRun, runAt, interval, serverId, CollectorRunTime.LocalIsUtc); + + /* ---- before and after the slot, with no history ---- */ + + [Fact] + public void NeverRun_BeforeTodaysSlot_WaitsForTodaysSlot() + { + Assert.Equal(Utc(2026, 10, 2, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 1, 0), null)); + Assert.Equal(Utc(2026, 10, 2, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 2, 0).AddTicks(-1), null)); + Assert.Equal(Utc(2026, 10, 2, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 0, 0), null)); + } + + [Fact] + public void NeverRun_AfterTodaysGrace_WaitsForTomorrowsSlot_NotNowAndNotAJitteredNow() + { + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 3, 0).AddTicks(1), null)); + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 12, 0), null)); + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 23, 59, 59), null)); + } + + [Theory] + [InlineData(2, 0, 0)] + [InlineData(2, 30, 0)] + [InlineData(2, 59, 59)] + [InlineData(3, 0, 0)] + public void NeverRun_InsideTheGrace_RunsNow(int hour, int minute, int second) + { + var now = Utc(2026, 10, 2, hour, minute, second); + + Assert.Equal(now, UtcClockNextDue(now, null)); + } + + [Fact] + public void TheGrace_IsSixtyMinutes_AndClosedAtBothEnds() + { + Assert.Equal(TimeSpan.FromMinutes(60), CollectorRunTime.Grace); + + var slot = Utc(2026, 10, 2, 2, 0); + Assert.Equal(slot, UtcClockNextDue(slot, null)); + Assert.Equal(slot + CollectorRunTime.Grace, UtcClockNextDue(slot + CollectorRunTime.Grace, null)); + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(slot + CollectorRunTime.Grace + TimeSpan.FromTicks(1), null)); + } + + [Fact] + public void ADateMinValueLastRun_IsReadAsNeverRun() + { + var now = Utc(2026, 10, 2, 12, 0); + + Assert.Equal(UtcClockNextDue(now, null), UtcClockNextDue(now, DateTime.MinValue)); + } + + /* ---- the spread ---- */ + + [Fact] + public void TheSpread_IsFixedPerId_AndUnderSixtyMinutes() + { + int[] ids = { 0, 1, 59, 1800, 3599, 3600, 3601, 123456789, -1, -61, -123456789, int.MaxValue, int.MinValue }; + foreach (var id in ids) + { + var spread = CollectorRunTime.Spread(id); + + Assert.Equal(TimeSpan.FromSeconds((uint)id % 3600), spread); + Assert.Equal(spread, CollectorRunTime.Spread(id)); + Assert.True(spread >= TimeSpan.Zero, $"id {id}: the spread must not pull the slot before the run time"); + Assert.True(spread < TimeSpan.FromMinutes(60), $"id {id}: the spread must stay under 60 minutes"); + } + } + + [Fact] + public void TheSlot_IsTheRunTimePlusTheServersSpread_OnTheServersClock() + { + var date = new DateOnly(2026, 10, 2); + + Assert.Equal(Utc(2026, 10, 2, 2, 0), CollectorRunTime.SlotUtc(date, TwoAm, NoSpreadId, CollectorRunTime.LocalIsUtc)); + Assert.Equal(Utc(2026, 10, 2, 2, 30), CollectorRunTime.SlotUtc(date, TwoAm, HalfHourSpreadId, CollectorRunTime.LocalIsUtc)); + Assert.Equal(Utc(2026, 10, 2, 2, 59, 59), CollectorRunTime.SlotUtc(date, TwoAm, LongSpreadId, CollectorRunTime.LocalIsUtc)); + + var eastern = ServerClock.Resolve(EasternIanaId, -300); + Assert.Equal(Utc(2026, 7, 1, 6, 0), CollectorRunTime.SlotUtc(new DateOnly(2026, 7, 1), TwoAm, NoSpreadId, eastern.ToUtc)); + Assert.Equal(Utc(2026, 1, 15, 7, 0), CollectorRunTime.SlotUtc(new DateOnly(2026, 1, 15), TwoAm, NoSpreadId, eastern.ToUtc)); + } + + [Fact] + public void WithASpread_TheGraceStartsAtTheSlot_NotAtTheRunTime() + { + /* Id 1800 puts the slot at 02:30: 02:15 is still before it, 03:20 is inside its grace, 03:31 is past it. */ + Assert.Equal(Utc(2026, 10, 2, 2, 30), UtcClockNextDue(Utc(2026, 10, 2, 2, 15), null, serverId: HalfHourSpreadId)); + Assert.Equal(Utc(2026, 10, 2, 3, 20), UtcClockNextDue(Utc(2026, 10, 2, 3, 20), null, serverId: HalfHourSpreadId)); + Assert.Equal(Utc(2026, 10, 3, 2, 30), UtcClockNextDue(Utc(2026, 10, 2, 3, 31), null, serverId: HalfHourSpreadId)); + } + + /* ---- after a run ---- */ + + [Fact] + public void AfterARun_TheNextDueIsTheNextDatesSlot() + { + var run = Utc(2026, 10, 2, 2, 10); + + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(run, run)); + + /* With 30 minutes of spread the slot is 02:30, so a run in its grace is 02:40; a run at 02:10 would be + before the slot and count for the day before. */ + var spreadRun = Utc(2026, 10, 2, 2, 40); + Assert.Equal(Utc(2026, 10, 3, 2, 30), UtcClockNextDue(spreadRun, spreadRun, serverId: HalfHourSpreadId)); + Assert.Equal(Utc(2026, 10, 2, 2, 30), UtcClockNextDue(run, run, serverId: HalfHourSpreadId)); + } + + [Fact] + public void ARestartInsideTheGraceAfterTheRun_DoesNotRunAgain() + { + var run = Utc(2026, 10, 2, 2, 10); + + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 2, 20), run)); + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 14, 0), run)); + } + + [Fact] + public void ADayMissedWhileDown_IsSkippedNotReplayed_AndWaitsForTheNextSlot() + { + var lastRun = Utc(2026, 9, 28, 2, 10); + + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 12, 0), lastRun)); + Assert.Equal(Utc(2026, 10, 2, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 1, 0), lastRun)); + } + + [Fact] + public void ADayMissedWhileDown_ButNowInsideTheGrace_RunsNow() + { + var lastRun = Utc(2026, 9, 28, 2, 10); + var now = Utc(2026, 10, 2, 2, 20); + + Assert.Equal(now, UtcClockNextDue(now, lastRun)); + } + + [Fact] + public void YesterdaysRunInsideItsGrace_LeavesTodaysSlotDue() + { + var lastRun = Utc(2026, 10, 1, 2, 50); + + Assert.Equal(Utc(2026, 10, 2, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 1, 0), lastRun)); + Assert.Equal(Utc(2026, 10, 2, 2, 5), UtcClockNextDue(Utc(2026, 10, 2, 2, 5), lastRun)); + } + + [Fact] + public void ARunBeforeTodaysSlot_CountsForTheDayBefore_SoTodaysSlotIsStillOwed() + { + var connectRun = Utc(2026, 10, 2, 1, 0); + + Assert.Equal(Utc(2026, 10, 2, 2, 0), UtcClockNextDue(connectRun, connectRun)); + } + + [Fact] + public void ASlotThatSpillsPastMidnight_BelongsToTheDateItWasComputedFor() + { + /* Run time 23:30 plus 59:59 of spread puts the 2026-10-02 slot at 00:29:59 on 2026-10-03. A run in its grace + is still the 10-02 run, so the next one is the 10-03 slot, a day later, not two. */ + const int lateRunAt = 23 * 60 + 30; + var run = Utc(2026, 10, 3, 0, 35); + + Assert.Equal(Utc(2026, 10, 3, 0, 29, 59), CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), lateRunAt, LongSpreadId, CollectorRunTime.LocalIsUtc)); + Assert.Equal(Utc(2026, 10, 4, 0, 29, 59), UtcClockNextDue(run, run, lateRunAt, serverId: LongSpreadId)); + } + + /* ---- whole-day intervals longer than one day ---- */ + + [Fact] + public void ATwoDayInterval_SkipsADay() + { + var run = Utc(2026, 10, 1, 2, 10); + + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(run, run, interval: 2880)); + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 12, 0), run, interval: 2880)); + Assert.Equal(Utc(2026, 10, 2, 2, 0), UtcClockNextDue(Utc(2026, 10, 1, 3, 0), Utc(2026, 9, 30, 2, 10), interval: 2880)); + } + + [Fact] + public void ATwoDayInterval_RunsInsideTheGraceOfItsDay_AndSkipsAMissedOne() + { + var run = Utc(2026, 10, 1, 2, 10); + var inside = Utc(2026, 10, 3, 2, 30); + + Assert.Equal(inside, UtcClockNextDue(inside, run, interval: 2880)); + + /* Past the grace of the 10-03 slot, that run is lost and the next one is two days on, on the same grid. */ + Assert.Equal(Utc(2026, 10, 5, 2, 0), UtcClockNextDue(Utc(2026, 10, 3, 3, 30), run, interval: 2880)); + Assert.Equal(Utc(2026, 10, 5, 2, 0), UtcClockNextDue(Utc(2026, 10, 4, 12, 0), run, interval: 2880)); + } + + [Fact] + public void ATwoDayInterval_NeverRun_WaitsForTheNextDailySlot() + { + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 12, 0), null, interval: 2880)); + } + + /* ---- the server's clock ---- */ + + [Theory] + [InlineData(EasternWindowsId)] + [InlineData(EasternIanaId)] + public void TheSpringForwardDay_KeepsTheRunTimeOnTheLocalClock(string zoneId) + { + var clock = ServerClock.Resolve(zoneId, -300); + + /* 04:00 local: 09:00Z in standard time on 03-07, 08:00Z in daylight time from 03-08. A run at 04:05 EST on the + 7th is followed by 04:00 EDT, 23 hours on; "due + 1440 minutes" would be 05:00 EDT. */ + var run = Utc(2026, 3, 7, 9, 5); + Assert.Equal(Utc(2026, 3, 8, 8, 0), NextDue(run, run, 240, Daily, NoSpreadId, clock.ToUtc)); + var dayAfter = Utc(2026, 3, 8, 8, 5); + Assert.Equal(Utc(2026, 3, 9, 8, 0), NextDue(dayAfter, dayAfter, 240, Daily, NoSpreadId, clock.ToUtc)); + + /* 02:00 local is inside the gap on the 8th, so it reads as 03:00 EDT (07:00Z), and the 9th is 02:00 EDT (06:00Z). */ + var twoRun = Utc(2026, 3, 7, 7, 10); + Assert.Equal(Utc(2026, 3, 8, 7, 0), NextDue(twoRun, twoRun, TwoAm, Daily, NoSpreadId, clock.ToUtc)); + var gapDayRun = Utc(2026, 3, 8, 7, 10); + Assert.Equal(Utc(2026, 3, 9, 6, 0), NextDue(gapDayRun, gapDayRun, TwoAm, Daily, NoSpreadId, clock.ToUtc)); + } + + [Theory] + [InlineData(EasternWindowsId)] + [InlineData(EasternIanaId)] + public void TheFallBackDay_KeepsTheRunTimeOnTheLocalClock(string zoneId) + { + var clock = ServerClock.Resolve(zoneId, -240); + + /* 02:00 EDT on 10-31 is 06:00Z; 02:00 EST on 11-01 is 07:00Z, 25 hours on. "due + 1440 minutes" would be + 06:00Z, which is 01:00 EST on the 1st. */ + var run = Utc(2026, 10, 31, 6, 10); + Assert.Equal(Utc(2026, 11, 1, 7, 0), NextDue(run, run, TwoAm, Daily, NoSpreadId, clock.ToUtc)); + var dayAfter = Utc(2026, 11, 1, 7, 10); + Assert.Equal(Utc(2026, 11, 2, 7, 0), NextDue(dayAfter, dayAfter, TwoAm, Daily, NoSpreadId, clock.ToUtc)); + + /* Before the change a never-run collector sees the daylight slot, after it the standard one. */ + Assert.Equal(Utc(2026, 11, 1, 7, 0), NextDue(Utc(2026, 11, 1, 5, 0), null, TwoAm, Daily, NoSpreadId, clock.ToUtc)); + } + + [Theory] + [InlineData(EasternWindowsId)] + [InlineData(EasternIanaId)] + public void EveryDayOfTheYear_TheNextSlotIsTheSameLocalTime(string zoneId) + { + var clock = ServerClock.Resolve(zoneId, -300); + var springForwardDay = new DateOnly(2026, 3, 8); + + /* Run time 02:00 plus the 30 minutes of spread for id 1800: 02:30 local, which does not exist on the spring + forward day and reads as 03:30 there. */ + var previous = CollectorRunTime.SlotUtc(new DateOnly(2026, 1, 1), TwoAm, HalfHourSpreadId, clock.ToUtc); + for (var date = new DateOnly(2026, 1, 2); date <= new DateOnly(2026, 12, 31); date = date.AddDays(1)) + { + var run = previous.AddMinutes(5); + var next = NextDue(run, run, TwoAm, Daily, HalfHourSpreadId, clock.ToUtc); + + Assert.Equal(CollectorRunTime.SlotUtc(date, TwoAm, HalfHourSpreadId, clock.ToUtc), next); + var expectedLocal = date.ToDateTime(date == springForwardDay ? new TimeOnly(3, 30) : new TimeOnly(2, 30)); + Assert.Equal(expectedLocal, clock.ToServerLocal(next)); + previous = next; + } + } + + [Fact] + public void AFixedOffsetClock_ReadsTheRunTimeOnTheServersClock_AcrossTheUtcDate() + { + /* +05:30: 02:00 local on the 3rd is 20:30Z on the 2nd. */ + var india = ServerClock.FixedOffset(330); + + Assert.Equal(Utc(2026, 10, 2, 20, 30), NextDue(Utc(2026, 10, 2, 12, 0), null, TwoAm, Daily, NoSpreadId, india.ToUtc)); + Assert.Equal(Utc(2026, 10, 2, 20, 30), NextDue(Utc(2026, 10, 2, 20, 0), null, TwoAm, Daily, NoSpreadId, india.ToUtc)); + Assert.Equal(Utc(2026, 10, 2, 21, 0), NextDue(Utc(2026, 10, 2, 21, 0), null, TwoAm, Daily, NoSpreadId, india.ToUtc)); + Assert.Equal(Utc(2026, 10, 3, 20, 30), NextDue(Utc(2026, 10, 2, 22, 0), null, TwoAm, Daily, NoSpreadId, india.ToUtc)); + + /* -10:00, run time 23:30: the 2026-10-02 slot is 09:30Z on the 3rd. */ + var hawaii = ServerClock.FixedOffset(-600); + const int lateRunAt = 23 * 60 + 30; + Assert.Equal(Utc(2026, 10, 3, 9, 30), NextDue(Utc(2026, 10, 2, 12, 0), null, lateRunAt, Daily, NoSpreadId, hawaii.ToUtc)); + } + + [Fact] + public void AnUnknownClock_IsUtc() + { + var instant = new DateTime(2026, 10, 2, 2, 0, 0, DateTimeKind.Unspecified); + + Assert.Equal(instant, CollectorRunTime.LocalIsUtc(instant)); + Assert.Equal(Utc(2026, 10, 3, 2, 0), UtcClockNextDue(Utc(2026, 10, 2, 12, 0), null)); + Assert.Equal(Utc(2026, 10, 3, 2, 0), NextDue(Utc(2026, 10, 2, 12, 0), null, TwoAm, Daily, NoSpreadId, ServerClock.Utc.ToUtc)); + } + + [Fact] + public void TheAnswers_AreUtcKind_WhateverKindTheClockReturns() + { + var eastern = ServerClock.Resolve(EasternIanaId, -300); + var now = Utc(2026, 10, 2, 12, 0); + + Assert.Equal(DateTimeKind.Utc, NextDue(now, null, TwoAm, Daily, NoSpreadId, eastern.ToUtc).Kind); + Assert.Equal(DateTimeKind.Utc, UtcClockNextDue(now, null).Kind); + Assert.Equal(DateTimeKind.Utc, CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), TwoAm, NoSpreadId, eastern.ToUtc).Kind); + } + + /* ---- every clock, against a brute force over each candidate date ---- */ + + private static IEnumerable<(string Name, Func ToUtc)> Clocks() + { + yield return ("unknown", CollectorRunTime.LocalIsUtc); + yield return ("-12:00", ServerClock.FixedOffset(-720).ToUtc); + yield return ("-05:00", ServerClock.FixedOffset(-300).ToUtc); + yield return ("+05:30", ServerClock.FixedOffset(330).ToUtc); + yield return ("+14:00", ServerClock.FixedOffset(840).ToUtc); + yield return ("US Eastern", ServerClock.Resolve(EasternIanaId, -300).ToUtc); + } + + private static IEnumerable Samples() + { + foreach (var start in new[] { Utc(2026, 3, 6), Utc(2026, 10, 30), Utc(2026, 10, 1) }) + { + for (var t = start; t < start.AddDays(4); t = t.AddMinutes(211)) + { + yield return t; + } + } + } + + /* The slow, obvious version: look at every date around now. The first slot at or after now, or now itself when + now is inside a slot's grace. */ + private static DateTime NeverRunByBruteForce(DateTime now, int runAt, int serverId, Func toUtc) + { + var around = DateOnly.FromDateTime(now); + DateTime? next = null; + for (var date = around.AddDays(-4); date <= around.AddDays(4); date = date.AddDays(1)) + { + var slot = CollectorRunTime.SlotUtc(date, runAt, serverId, toUtc); + if (slot <= now && now <= slot + CollectorRunTime.Grace) + { + return now; + } + + if (slot > now && (next is null || slot < next)) + { + next = slot; + } + } + + return next!.Value; + } + + /* The date a run belongs to is the latest date whose slot is at or before it. The slots after it are every + `days` dates on; the first whose grace has not passed is due, or now when now is already inside it. */ + private static DateTime WithLastRunByBruteForce(DateTime now, DateTime lastRun, int days, int runAt, int serverId, + Func toUtc) + { + var around = DateOnly.FromDateTime(lastRun); + var lastDate = around.AddDays(-4); + for (var date = around.AddDays(-4); date <= around.AddDays(4); date = date.AddDays(1)) + { + if (CollectorRunTime.SlotUtc(date, runAt, serverId, toUtc) <= lastRun) + { + lastDate = date; + } + } + + for (var step = 1; ; step++) + { + var slot = CollectorRunTime.SlotUtc(lastDate.AddDays(step * days), runAt, serverId, toUtc); + if (now <= slot + CollectorRunTime.Grace) + { + return slot <= now ? now : slot; + } + } + } + + [Fact] + public void NeverRun_OnEveryClock_IsTheFirstSlotAheadOrNowInsideAGrace() + { + foreach (var (name, toUtc) in Clocks()) + { + foreach (var runAt in new[] { 0, 119, 1439 }) + { + foreach (var id in new[] { NoSpreadId, HalfHourSpreadId, LongSpreadId }) + { + foreach (var now in Samples()) + { + var actual = NextDue(now, null, runAt, Daily, id, toUtc); + + Assert.True(NeverRunByBruteForce(now, runAt, id, toUtc) == actual, + $"{name}, run at {runAt}, id {id}, now {now:O}: got {actual:O}"); + Assert.True(actual - now <= CollectorRunTime.MaxStampAhead(Daily), + $"{name}, run at {runAt}, id {id}, now {now:O}: {actual:O} is further ahead than the clamp bound"); + } + } + } + } + } + + [Fact] + public void WithALastRun_OnEveryClock_IsTheNextGridSlotOrNowInsideItsGrace() + { + var back = new[] { TimeSpan.FromMinutes(10), TimeSpan.FromHours(5), TimeSpan.FromHours(30), TimeSpan.FromHours(70) }; + foreach (var (name, toUtc) in Clocks()) + { + foreach (var runAt in new[] { 0, 119, 1439 }) + { + foreach (var id in new[] { NoSpreadId, LongSpreadId }) + { + foreach (var days in new[] { 1, 2, 3 }) + { + foreach (var now in Samples()) + { + foreach (var ago in back) + { + var lastRun = now - ago; + var actual = NextDue(now, lastRun, runAt, days * Daily, id, toUtc); + + Assert.True(WithLastRunByBruteForce(now, lastRun, days, runAt, id, toUtc) == actual, + $"{name}, run at {runAt}, id {id}, {days} d, now {now:O}, last run {lastRun:O}: got {actual:O}"); + Assert.True(actual - now <= CollectorRunTime.MaxStampAhead(days * Daily), + $"{name}, run at {runAt}, id {id}, {days} d, now {now:O}, last run {lastRun:O}: {actual:O} is further ahead than the clamp bound"); + } + } + } + } + } + } + } + + /* ---- the clamp bound ---- */ + + [Fact] + public void TheClampBound_IsTheIntervalPlusSixtyMinutesOfSpreadPlusAnAutumnHour() + { + Assert.Equal(TimeSpan.FromMinutes(1440 + 60 + 60), CollectorRunTime.MaxStampAhead(1440)); + Assert.Equal(TimeSpan.FromMinutes(2880 + 60 + 60), CollectorRunTime.MaxStampAhead(2880)); + } + + [Fact] + public void ARunTimeStampOnTheLongestDay_IsNotReadAsAClockStep() + { + var eastern = ServerClock.Resolve(EasternIanaId, -240); + var run = Utc(2026, 10, 31, 6, 0); + var due = NextDue(run, run, TwoAm, Daily, NoSpreadId, eastern.ToUtc); + + Assert.Equal(Utc(2026, 11, 1, 7, 0), due); + Assert.Equal(TimeSpan.FromHours(25), due - run); + + /* The plain interval reads a 25-hour wait as a clock that stepped back and runs it now. The bound does not. */ + Assert.Equal(run, CollectorCadence.ClampDue(due, run, TimeSpan.FromMinutes(Daily))); + Assert.Equal(due, CollectorCadence.ClampDue(due, run, CollectorRunTime.MaxStampAhead(Daily))); + } + + [Fact] + public void AStampMuchFurtherAheadThanTheBound_IsStillReadAsAClockStep() + { + var now = Utc(2026, 10, 2, 12, 0); + var due = now.AddDays(3); + + Assert.Equal(now, CollectorCadence.ClampDue(due, now, CollectorRunTime.MaxStampAhead(Daily))); + } + + /* ---- which collectors take a run time ---- */ + + [Theory] + [InlineData(60, false)] + [InlineData(720, false)] + [InlineData(1439, false)] + [InlineData(1440, true)] + [InlineData(1441, false)] + [InlineData(2880, true)] + [InlineData(4320, true)] + [InlineData(10080, true)] + [InlineData(0, false)] + [InlineData(-1440, false)] + public void AllowsRunAt_OnlyForAPositiveWholeNumberOfDays(int intervalMinutes, bool expected) + { + Assert.Equal(expected, CollectorRunTime.AllowsRunAt(intervalMinutes)); + } + + [Fact] + public void NextDue_RefusesWhatNoRunTimeCanMean() + { + var now = Utc(2026, 10, 2, 12, 0); + + Assert.Throws(() => UtcClockNextDue(now, null, interval: 60)); + Assert.Throws(() => UtcClockNextDue(now, null, interval: 0)); + Assert.Throws(() => UtcClockNextDue(now, null, runAt: -1)); + Assert.Throws(() => UtcClockNextDue(now, null, runAt: 1440)); + Assert.Throws(() => NextDue(now, null, TwoAm, Daily, NoSpreadId, null!)); + Assert.Throws(() => + CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), 1440, NoSpreadId, CollectorRunTime.LocalIsUtc)); + } + + /* ---- the HH:MM text ---- */ + + [Theory] + [InlineData("00:00", 0)] + [InlineData("02:00", 120)] + [InlineData("02:05", 125)] + [InlineData("12:30", 750)] + [InlineData("23:59", 1439)] + [InlineData(" 02:00 ", 120)] + public void TryParse_AcceptsA24HourTime(string text, int expectedMinute) + { + Assert.True(CollectorRunTime.TryParse(text, out var minute)); + Assert.Equal(expectedMinute, minute); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("24:00")] + [InlineData("23:60")] + [InlineData("99:99")] + [InlineData("2:5")] + [InlineData("2:00")] + [InlineData("02:5")] + [InlineData("abc")] + [InlineData("ab:cd")] + [InlineData("02-00")] + [InlineData("0200")] + [InlineData("02:000")] + [InlineData("02:00:00")] + [InlineData("-1:00")] + [InlineData("+2:00")] + [InlineData("02:00")] + [InlineData("02:00 PM")] + public void TryParse_RefusesAnythingButHhMm(string? text) + { + Assert.False(CollectorRunTime.TryParse(text, out var minute)); + Assert.Equal(0, minute); + } + + [Fact] + public void Format_RoundTripsEveryMinuteOfTheDay() + { + var shape = new Regex("^[0-2][0-9]:[0-5][0-9]$"); + for (var minute = 0; minute < 1440; minute++) + { + var text = CollectorRunTime.Format(minute); + + Assert.Matches(shape, text); + Assert.True(CollectorRunTime.TryParse(text, out var parsed), text); + Assert.Equal(minute, parsed); + } + + Assert.Equal("00:00", CollectorRunTime.Format(0)); + Assert.Equal("02:05", CollectorRunTime.Format(125)); + Assert.Equal("23:59", CollectorRunTime.Format(1439)); + } + + [Fact] + public void Format_RefusesAMinuteOutsideTheDay() + { + Assert.Throws(() => CollectorRunTime.Format(-1)); + Assert.Throws(() => CollectorRunTime.Format(1440)); + } + + /* ---- the one copy of the error texts ---- */ + + [Fact] + public void TheErrorTexts_AreTheOnesEveryEditorShows() + { + Assert.Equal("Run at must be a 24-hour time from 00:00 to 23:59, such as 02:00.", CollectorRunTime.InvalidRunAtMessage); + Assert.Equal( + "'wait_stats' runs every 60 minutes. A run time works only for a collector that runs once a day or less often (1440 minutes, or a multiple of 1440). Change the frequency or clear the run time.", + CollectorRunTime.IntervalRefusalMessage("wait_stats", 60)); + Assert.Equal( + "'server_config' runs every 720 minutes. A run time works only for a collector that runs once a day or less often (1440 minutes, or a multiple of 1440). Change the frequency or clear the run time.", + CollectorRunTime.IntervalRefusalMessage("server_config", 720)); + } +} diff --git a/Darling/Darling.Tests/CollectorRunTimeViewerLiveTests.cs b/Darling/Darling.Tests/CollectorRunTimeViewerLiveTests.cs new file mode 100644 index 0000000000..54f7244695 --- /dev/null +++ b/Darling/Darling.Tests/CollectorRunTimeViewerLiveTests.cs @@ -0,0 +1,545 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// The Collector Schedules window's "Run at" column against a real store (#4938): the read, the save and the clear, +/// for the fleet and for a server, driven the way the window drives them (read the schedule rows and the run times, +/// build the editable schedule, edit a cell, validate, then write the run-time changes and the schedule rows in ONE +/// transaction). The run time +/// lives in config.config_collector_run_times, a table of its own, so a schedule Save never carries it. What the +/// service then resolves from the stored rows is checked with its own , +/// so "None on a server over a fleet time" is judged by the rule the collectors run on and not by a restatement of it. +/// +/* #1776 own-store: each fact mints its own scratch database through ScratchPostgres and never touches the shared + store's tables, so it cannot race the live collection and serializing it would be pure slowdown. */ +public sealed class CollectorRunTimeViewerLiveTests +{ + private const string Daily = "index_object_stats"; + private const string AnotherDaily = "server_properties"; + private const string SkipText = "DARLING_TEST_PG is not set: the viewer run-time tests need a disposable PostgreSQL store."; + + private static string? ConnectionString => Environment.GetEnvironmentVariable("DARLING_TEST_PG"); + + private static async Task ScalarAsync(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + return await command.ExecuteScalarAsync(ct); + } + + private static async Task ExecAsync(NpgsqlConnection connection, string sql, CancellationToken ct) + { + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(ct); + } + + /// Every stored run time, one text: fleet/collector=minute or serverId/collector=minute. + private static async Task RunTimesAsync(NpgsqlConnection connection, CancellationToken ct) => + (string)(await ScalarAsync(connection, + "SELECT COALESCE(string_agg(COALESCE(server_id::text, 'fleet') || '/' || collector_name || '=' || run_at_minute, ',' ORDER BY server_id NULLS FIRST, collector_name), '') FROM config.config_collector_run_times", ct))!; + + private static async Task ScheduleRowCountAsync(NpgsqlConnection connection, CancellationToken ct) => + (long)(await ScalarAsync(connection, "SELECT count(*) FROM config.config_collector_schedules", ct))!; + + /// The window's own steps for a scope: read both tables, build the editable schedule, let the caller edit it, + /// validate, then ONE call that writes the run-time changes (one statement each) and the schedule rows, exactly as a + /// released viewer writes them, in one transaction. + private static async Task SaveAsync( + ViewerDataService viewer, int? serverId, Action> edit, CancellationToken ct, + bool usesDefault = false, bool resetToDefaults = false, Func? afterOpen = null) + { + var schedules = await viewer.GetCollectorSchedulesAsync(ct); + var runTimes = await viewer.GetCollectorRunTimesAsync(ct); + var editing = CollectorScheduleOverlay.BuildEffectiveSchedule(schedules, runTimes, usesDefault ? null : serverId); + if (resetToDefaults) + { + /* The window's Reset to Defaults replaces the whole grid with the shipped defaults. */ + editing = CollectorSchedulePresets.BuildDefaultSchedule(); + } + + edit(editing); + + /* Another writer (the CLI verb, another viewer) can change the store while the window is open. */ + if (afterOpen is not null) + { + await afterOpen(); + } + + if (!usesDefault) + { + Assert.True(CollectorScheduleOverlay.ValidateSchedule(editing, out var error), error); + } + + /* The window's own rule: a schedule Reset (Reset to Defaults, or a server on "Use default schedule") also clears the scope's + run-time rows, by scope and in the same transaction. */ + var clearRunTimes = resetToDefaults || usesDefault; + var changes = CollectorScheduleOverlay.ToRunTimeChanges(editing, runTimes, serverId, usesDefault, clearRunTimes); + var rows = serverId is int sid + ? (usesDefault ? new List() : CollectorScheduleOverlay.ToServerOverrideRows(editing, sid)) + : CollectorScheduleOverlay.ToFleetOverrideRows(editing); + await viewer.SaveCollectorScheduleAsync(serverId, rows, changes, clearRunTimes, ct); + } + + /// The fleet row's stored frequency for the daily collector, or null when there is no such row (or no frequency on it). + private static async Task FleetFrequencyAsync(NpgsqlConnection connection, CancellationToken ct) => + await ScalarAsync(connection, + $"SELECT frequency_minutes FROM config.config_collector_schedules WHERE server_id IS NULL AND collector_name = '{Daily}'", ct) is int minutes + ? minutes + : null; + + private static void SetRunAt(List editing, string text, string collector = Daily) => + editing.Single(i => i.Name == collector).RunAtText = text; + + /// What the service resolves for the collector on the server, from the store's own rows. + private static async Task ResolvedRunAtAsync(ViewerDataService viewer, int serverId, CancellationToken ct) + { + var schedules = (await viewer.GetCollectorSchedulesAsync(ct)) + .Select(r => new ScheduleOverride(r.ServerId, r.CollectorName, r.FrequencyMinutes, r.RetentionDays, r.Enabled, r.Databases)) + .ToList(); + var runTimes = (await viewer.GetCollectorRunTimesAsync(ct)) + .Select(r => new RunTimeOverride(r.ServerId, r.CollectorName, r.RunAtMinute)) + .ToList(); + return StoreConfigProvider.ResolveSchedule(Daily, serverId, StoreConfigProvider.MergeRunTimes(schedules, runTimes)).RunAtMinute; + } + + [Fact] + public async Task TheFleetRunTime_IsSetKeptAndCleared_ThroughTheRunTimeTable_AndNeverAScheduleRow() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + /* Nothing stored: the cell reads "Use default" and a save writes nothing anywhere. */ + await SaveAsync(viewer, null, editing => Assert.Equal("Use default", editing.Single(i => i.Name == Daily).RunAtText), ct); + Assert.Equal("", await RunTimesAsync(connection, ct)); + Assert.Equal(0L, await ScheduleRowCountAsync(connection, ct)); + + /* SET: a fleet time on a collector left at its default cadence is a run-time row and NO schedule row. */ + await SaveAsync(viewer, null, editing => SetRunAt(editing, "02:00"), ct); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(0L, await ScheduleRowCountAsync(connection, ct)); + var read = Assert.Single(await viewer.GetCollectorRunTimesAsync(ct)); + Assert.Equal(new CollectorRunTimeRow(null, Daily, 120), read); + + /* KEEP: the window reads the stored time into the cell, and a save that edits only a schedule column (the + schedule rows are deleted and inserted again) leaves the run time as it was. */ + await SaveAsync(viewer, null, editing => + { + Assert.Equal("02:00", editing.Single(i => i.Name == Daily).RunAtText); + editing.Single(i => i.Name == AnotherDaily).RetentionDays = 77; + }, ct); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(1L, await ScheduleRowCountAsync(connection, ct)); + + /* EDIT: a new time replaces the old one in the same row. */ + await SaveAsync(viewer, null, editing => SetRunAt(editing, "03:15"), ct); + Assert.Equal($"fleet/{Daily}=195", await RunTimesAsync(connection, ct)); + + /* CLEAR: "Use default" deletes the row, and so does "None" (the fleet has nothing to stop). */ + await SaveAsync(viewer, null, editing => SetRunAt(editing, "Use default"), ct); + Assert.Equal("", await RunTimesAsync(connection, ct)); + await SaveAsync(viewer, null, editing => SetRunAt(editing, "01:00"), ct); + await SaveAsync(viewer, null, editing => SetRunAt(editing, "None"), ct); + Assert.Equal("", await RunTimesAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task AServerRunTime_UsesDefaultNoneOrItsOwnTime_AndNoneStopsAFleetTime() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + await SaveAsync(viewer, null, editing => SetRunAt(editing, "02:00"), ct); + Assert.Equal(120, await ResolvedRunAtAsync(viewer, 7, ct)); + + /* "Use default" on a customizing server writes no row for it, so the fleet's time applies. */ + await SaveAsync(viewer, 7, editing => Assert.Equal("Use default", editing.Single(i => i.Name == Daily).RunAtText), ct); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(120, await ResolvedRunAtAsync(viewer, 7, ct)); + + /* ITS OWN TIME replaces the fleet's for that server only. */ + await SaveAsync(viewer, 7, editing => SetRunAt(editing, "04:30"), ct); + Assert.Equal($"fleet/{Daily}=120,7/{Daily}=270", await RunTimesAsync(connection, ct)); + Assert.Equal(270, await ResolvedRunAtAsync(viewer, 7, ct)); + Assert.Equal(120, await ResolvedRunAtAsync(viewer, 8, ct)); + + /* NONE is -1 on the server's row, which stops the fleet's time for that server. */ + await SaveAsync(viewer, 7, editing => SetRunAt(editing, "None"), ct); + Assert.Equal($"fleet/{Daily}=120,7/{Daily}=-1", await RunTimesAsync(connection, ct)); + Assert.Null(await ResolvedRunAtAsync(viewer, 7, ct)); + Assert.Equal(120, await ResolvedRunAtAsync(viewer, 8, ct)); + + /* "Use the default schedule" deletes the server's schedule rows AND its run-time rows. */ + await SaveAsync(viewer, 7, _ => { }, ct, usesDefault: true); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(120, await ResolvedRunAtAsync(viewer, 7, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + [Fact] + public async Task AScheduleSave_ThroughTheViewersRealSql_LeavesEveryRunTimeAlone_AndApplyDefaultToAllRemovesOnlyTheServerRunTimes() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await using var viewer = new ViewerDataService(scratch.ConnectionString); + await ExecAsync(connection, + $"INSERT INTO config.config_collector_run_times (server_id, collector_name, run_at_minute) VALUES (NULL, '{Daily}', 0), (3, '{Daily}', -1), (3, '{AnotherDaily}', 1439)", ct); + const string Expected = $"fleet/{Daily}=0,3/{Daily}=-1,3/{AnotherDaily}=1439"; + + /* The viewer's own schedule statements, the fleet's and a server's, replace schedule rows only. */ + await viewer.ReplaceFleetSchedulesAsync(new[] { new CollectorScheduleRow(null, Daily, 1440, 30, true) }, ct); + await viewer.ReplaceServerSchedulesAsync(3, new[] { new CollectorScheduleRow(3, Daily, null, null, true) }, ct); + await viewer.ReplaceServerSchedulesAsync(3, Array.Empty(), ct); + Assert.Equal(Expected, await RunTimesAsync(connection, ct)); + + /* The read returns the rows as stored: minute 0, the last minute of the day, and -1 on a server row. */ + var rows = await viewer.GetCollectorRunTimesAsync(ct); + Assert.Equal(0, rows.Single(r => r.ServerId is null).RunAtMinute); + Assert.Equal(-1, rows.Single(r => r.ServerId == 3 && r.CollectorName == Daily).RunAtMinute); + Assert.Equal(1439, rows.Single(r => r.ServerId == 3 && r.CollectorName == AnotherDaily).RunAtMinute); + + /* "Apply Default to All Servers" is one call: it sends every server back to the fleet, run times included (no server + schedule rows are left at this point, so the count is the two server run times), and keeps the fleet's. */ + var removed = await viewer.ResetAllServerSchedulesAsync(ct); + Assert.Equal(new CollectorScheduleResetCounts(ScheduleOverrides: 0, RunTimes: 2), removed); + Assert.Equal($"fleet/{Daily}=0", await RunTimesAsync(connection, ct)); + + /* A reset of run times alone says so (#4938): it is not reported as schedule overrides. */ + Assert.Equal( + "Reset 2 per-server run times — every server now follows the fleet default.", + CollectorScheduleOverlay.FormatResetStatus(removed)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// The viewer's schema probe fails open when it throws, so a store below V160 can reach the editor. The run-time read + /// must then be "no run times" (42P01 undefined_table), the editor's Run at cells must all read "Use default", and saving a run + /// time must say the store is older than this viewer (not the raw error). A schedule save and the reset still work. + [Fact] + public async Task AStoreWithoutTheRunTimeTable_ReadsNoRunTimes_AndASaveSaysTheStoreIsOlder() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await ExecAsync(connection, "DROP TABLE config.config_collector_run_times", ct); + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + Assert.Empty(await viewer.GetCollectorRunTimesAsync(ct)); + var editing = CollectorScheduleOverlay.BuildEffectiveSchedule( + await viewer.GetCollectorSchedulesAsync(ct), await viewer.GetCollectorRunTimesAsync(ct), null); + Assert.All(editing, i => Assert.Equal(CollectorScheduleOverlay.UseDefaultRunAtText, i.RunAtText)); + + var skew = await Assert.ThrowsAsync(() => + viewer.SaveCollectorRunTimesAsync(new[] { new CollectorRunTimeChange(null, Daily, 120) }, ct)); + Assert.Contains("Update or restart the Darling service", skew.Message, StringComparison.Ordinal); + Assert.IsType(skew.InnerException); + + /* Everything that does not touch a run time still works on such a store, the all-servers reset included: it removes the + server's schedule rows and has no run-time table to clear. */ + await viewer.ReplaceFleetSchedulesAsync(new[] { new CollectorScheduleRow(null, Daily, 1440, 30, true) }, ct); + await viewer.ReplaceServerSchedulesAsync(3, new[] { new CollectorScheduleRow(3, Daily, 720, 30, true) }, ct); + Assert.Equal(2L, await ScheduleRowCountAsync(connection, ct)); + Assert.Equal(new CollectorScheduleResetCounts(ScheduleOverrides: 1, RunTimes: 0), await viewer.ResetAllServerSchedulesAsync(ct)); + Assert.Equal(1L, await ScheduleRowCountAsync(connection, ct)); + + /* The window's one-transaction Save on such a store: a Save that changed no run time sends no run-time statement and + still saves its schedules; one that did says the store is older, and the schedule rows of that same Save are NOT + written (they share its transaction). */ + await viewer.SaveCollectorScheduleAsync( + null, new[] { new CollectorScheduleRow(null, Daily, 720, 30, true) }, Array.Empty(), ct); + Assert.Equal(720, await FleetFrequencyAsync(connection, ct)); + var oneTransaction = await Assert.ThrowsAsync(() => viewer.SaveCollectorScheduleAsync( + null, new[] { new CollectorScheduleRow(null, Daily, 60, 30, true) }, new[] { new CollectorRunTimeChange(null, Daily, 120) }, ct)); + Assert.Contains("Update or restart the Darling service", oneTransaction.Message, StringComparison.Ordinal); + Assert.Equal(720, await FleetFrequencyAsync(connection, ct)); + + /* A schedule Reset's Save on such a store has no run-time table to clear: the clear is skipped and the schedules are saved. */ + await viewer.SaveCollectorScheduleAsync( + null, new[] { new CollectorScheduleRow(null, Daily, 480, 30, true) }, Array.Empty(), true, ct); + Assert.Equal(480, await FleetFrequencyAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// The window's Save is ONE transaction on one connection (#4938). The run-time write comes first, so a schedule write + /// that fails after it (here a frequency the schedules table's own CHECK refuses) must take the run time back with it, for the + /// fleet and for a server; a run-time write that fails (the run-time table's CHECK refuses -1 on a fleet row) must leave the + /// schedule rows of that Save as they were; and the same Save with a valid schedule is the control that both halves land + /// together. + [Fact] + public async Task AScheduleWriteThatFails_LeavesTheRunTimeUnsaved_BecauseTheSaveIsOneTransaction() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + /* The control: a valid fleet Save lands the run time and the schedule row together. */ + await viewer.SaveCollectorScheduleAsync( + null, new[] { new CollectorScheduleRow(null, Daily, 1440, 30, true) }, new[] { new CollectorRunTimeChange(null, Daily, 120) }, ct); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(1440, await FleetFrequencyAsync(connection, ct)); + + /* FLEET: the run time moves 120 -> 300 and the schedule write then fails (frequency_minutes >= 0 refuses -1). Neither + half may stay: the run time is still 120 and the schedule row is still the one the control wrote. */ + var fleetFailure = await Assert.ThrowsAsync(() => viewer.SaveCollectorScheduleAsync( + null, new[] { new CollectorScheduleRow(null, Daily, -1, 30, true) }, new[] { new CollectorRunTimeChange(null, Daily, 300) }, ct)); + Assert.Equal("23514", fleetFailure.SqlState); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(1440, await FleetFrequencyAsync(connection, ct)); + Assert.Equal(1L, await ScheduleRowCountAsync(connection, ct)); + + /* SERVER: a new server run time and a refused server schedule row. No server run time, no server schedule row. */ + var serverFailure = await Assert.ThrowsAsync(() => viewer.SaveCollectorScheduleAsync( + 3, new[] { new CollectorScheduleRow(3, Daily, -1, null, true) }, new[] { new CollectorRunTimeChange(3, Daily, 180) }, ct)); + Assert.Equal("23514", serverFailure.SqlState); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(1L, await ScheduleRowCountAsync(connection, ct)); + + /* THE OTHER WAY: a run-time write the run-time table refuses (-1 is a server-row value only) leaves the schedule rows of + that Save as they were, so the 1440 the control wrote is not replaced by this Save's 720. */ + var runTimeFailure = await Assert.ThrowsAsync(() => viewer.SaveCollectorScheduleAsync( + null, new[] { new CollectorScheduleRow(null, Daily, 720, 30, true) }, new[] { new CollectorRunTimeChange(null, Daily, -1) }, ct)); + Assert.Equal("23514", runTimeFailure.SqlState); + Assert.Equal($"fleet/{Daily}=120", await RunTimesAsync(connection, ct)); + Assert.Equal(1440, await FleetFrequencyAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + private const string RunTimeRows = "INSERT INTO config.config_collector_run_times (server_id, collector_name, run_at_minute) VALUES "; + + private const string ScheduleRows = "INSERT INTO config.config_collector_schedules (server_id, collector_name, frequency_minutes) VALUES "; + + /// "Apply Default to All Servers" is ONE call (#4938): it deletes every server's schedule rows and every server's run + /// times, and keeps the fleet's schedule rows and run times. A server's run time is part of its override, and Reset means back + /// to the shipped defaults, which have no fixed time. It returns the rows removed from each table, counted apart (#4938). + [Fact] + public async Task ApplyDefaultToAllServers_RemovesEveryServersScheduleRowsAndRunTimes_AndKeepsTheFleets() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await ExecAsync(connection, ScheduleRows + $"(NULL, '{Daily}', 1440), (3, '{Daily}', 2880), (7, '{AnotherDaily}', 720)", ct); + await ExecAsync(connection, RunTimeRows + $"(NULL, '{Daily}', 0), (3, '{Daily}', -1), (3, '{AnotherDaily}', 1439), (7, '{Daily}', 300)", ct); + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + /* Two server schedule rows and three server run times, in one call. */ + Assert.Equal(new CollectorScheduleResetCounts(ScheduleOverrides: 2, RunTimes: 3), await viewer.ResetAllServerSchedulesAsync(ct)); + Assert.Equal(1L, await ScheduleRowCountAsync(connection, ct)); + Assert.Equal(1440, await FleetFrequencyAsync(connection, ct)); + Assert.Equal($"fleet/{Daily}=0", await RunTimesAsync(connection, ct)); + + /* Nothing left that belongs to a server, so a second call removes nothing. */ + Assert.Equal(new CollectorScheduleResetCounts(ScheduleOverrides: 0, RunTimes: 0), await viewer.ResetAllServerSchedulesAsync(ct)); + Assert.Equal($"fleet/{Daily}=0", await RunTimesAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// The all-servers reset is one transaction (#4938): a failure after the schedule delete, here the run-time delete + /// refused by a trigger, leaves the schedule rows AND the run times in place, and the same call with the trigger gone is the + /// control that both halves land together. + [Fact] + public async Task ApplyDefaultToAllServers_ThatFailsAfterTheScheduleDelete_LeavesTheScheduleRowsAndTheRunTimesInPlace() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await ExecAsync(connection, ScheduleRows + $"(NULL, '{Daily}', 1440), (3, '{Daily}', 2880), (7, '{AnotherDaily}', 720)", ct); + await ExecAsync(connection, RunTimeRows + $"(NULL, '{Daily}', 0), (3, '{Daily}', -1), (7, '{Daily}', 300)", ct); + const string Before = $"fleet/{Daily}=0,3/{Daily}=-1,7/{Daily}=300"; + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + /* The schedule delete runs first, so a refused run-time delete has to take it back. */ + await ExecAsync(connection, + "CREATE FUNCTION public.refuse_run_time_delete() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'run times cannot be deleted'; END $$", ct); + await ExecAsync(connection, + "CREATE TRIGGER trg_refuse_run_time_delete BEFORE DELETE ON config.config_collector_run_times FOR EACH STATEMENT EXECUTE FUNCTION public.refuse_run_time_delete()", ct); + + var failure = await Assert.ThrowsAsync(() => viewer.ResetAllServerSchedulesAsync(ct)); + Assert.Equal("P0001", failure.SqlState); + Assert.Equal(3L, await ScheduleRowCountAsync(connection, ct)); + Assert.Equal(2L, await ScalarAsync(connection, "SELECT count(*) FROM config.config_collector_schedules WHERE server_id IS NOT NULL", ct)); + Assert.Equal(Before, await RunTimesAsync(connection, ct)); + + /* The control: without the trigger the same call removes both. */ + await ExecAsync(connection, "DROP TRIGGER trg_refuse_run_time_delete ON config.config_collector_run_times", ct); + Assert.Equal(new CollectorScheduleResetCounts(ScheduleOverrides: 2, RunTimes: 2), await viewer.ResetAllServerSchedulesAsync(ct)); + Assert.Equal(1L, await ScheduleRowCountAsync(connection, ct)); + Assert.Equal($"fleet/{Daily}=0", await RunTimesAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } + + /// A schedule Reset deletes that scope's run-time rows, in the same Save (#4938). Reset to Defaults replaces the + /// grid with the shipped defaults, which have no fixed time, so the Save leaves no run-time row for the scope: not the ones the + /// window showed, not one another writer added while the window was open, and not one for a collector this build does not + /// define. A server that "uses the default schedule" is the same Reset for that server. Every other scope keeps its rows. + [Fact] + public async Task ASaveAfterAReset_LeavesNoRunTimeRowForTheScope_AndKeepsEveryOtherScopes() + { + Assert.SkipWhen(string.IsNullOrEmpty(ConnectionString), SkipText); + var ct = TestContext.Current.CancellationToken; + + await using var scratch = await ScratchPostgres.CreateAsync(ConnectionString!, ct); + await using var connection = new NpgsqlConnection(scratch.ConnectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + var bodySucceeded = false; + try + { + await ExecAsync(connection, + RunTimeRows + $"(NULL, '{Daily}', 120), (NULL, 'a_collector_this_build_lacks', 30), (7, '{Daily}', 300), (7, 'a_collector_this_build_lacks', 60), (8, '{Daily}', 200), (9, '{Daily}', 240)", ct); + await using var viewer = new ViewerDataService(scratch.ConnectionString); + + /* SERVER 7: it holds a time for the collector the window shows, one for a collector this build does not define, and + a third that another writer adds while the window is open. The Reset removes all three, and no other scope's. */ + await SaveAsync(viewer, 7, _ => { }, ct, resetToDefaults: true, + afterOpen: () => ExecAsync(connection, RunTimeRows + $"(7, '{AnotherDaily}', 1439)", ct)); + Assert.Equal( + $"fleet/a_collector_this_build_lacks=30,fleet/{Daily}=120,8/{Daily}=200,9/{Daily}=240", + await RunTimesAsync(connection, ct)); + + /* A time typed into the grid after the Reset is kept: the Save clears the scope, then writes what the grid holds. */ + await ExecAsync(connection, RunTimeRows + $"(8, '{AnotherDaily}', 90)", ct); + await SaveAsync(viewer, 8, editing => SetRunAt(editing, "04:00"), ct, resetToDefaults: true); + Assert.Equal( + $"fleet/a_collector_this_build_lacks=30,fleet/{Daily}=120,8/{Daily}=240,9/{Daily}=240", + await RunTimesAsync(connection, ct)); + + /* "Use default schedule" for a server is the same Reset for that server. */ + await SaveAsync(viewer, 9, _ => { }, ct, usesDefault: true, + afterOpen: () => ExecAsync(connection, RunTimeRows + $"(9, '{AnotherDaily}', 45)", ct)); + Assert.Equal( + $"fleet/a_collector_this_build_lacks=30,fleet/{Daily}=120,8/{Daily}=240", + await RunTimesAsync(connection, ct)); + + /* THE FLEET: Reset to Defaults clears the fleet's rows (and the fleet only). */ + await SaveAsync(viewer, null, _ => { }, ct, resetToDefaults: true); + Assert.Equal($"8/{Daily}=240", await RunTimesAsync(connection, ct)); + + bodySucceeded = true; + } + finally + { + await LiveStoreCleanup.RunAsync(scratch.ConnectionString, bodySucceeded, async (_, _) => { }); + } + } +} diff --git a/Darling/Darling.Tests/CollectorRunTimeViewerTests.cs b/Darling/Darling.Tests/CollectorRunTimeViewerTests.cs new file mode 100644 index 0000000000..2c2c611698 --- /dev/null +++ b/Darling/Darling.Tests/CollectorRunTimeViewerTests.cs @@ -0,0 +1,403 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// The Collector Schedules window's "Run at" column (#4938), without a store: what the cell holds for each scope, +/// the rows a save writes from it, the checks a save runs and the text they show, and the conversion line under the +/// grid. All of it is pure, so it is tested without a window. The store round trip is +/// . +/// +public sealed class CollectorRunTimeViewerTests +{ + private const string Daily = "index_object_stats"; + private const string AnotherDaily = "pg_index_usage_stats"; + private const string OnLoad = "server_properties"; + private const string Hourly = "wait_stats"; + + private static CollectorScheduleEditItem Item(string name, string runAt, int? frequency = null) + { + var item = CollectorSchedulePresets.BuildDefaultSchedule().Single(i => i.Name == name); + item.RunAtText = runAt; + if (frequency is int f) + { + item.FrequencyMinutes = f; + } + + return item; + } + + private static bool Valid(CollectorScheduleEditItem item, out string error) => + CollectorScheduleOverlay.ValidateSchedule(new[] { item }, out error); + + [Fact] + public void ANewItem_ShowsUseDefault_AndSoDoesEveryItemOfTheDefaultSchedule() + { + Assert.Equal("Use default", CollectorScheduleOverlay.UseDefaultRunAtText); + Assert.Equal("None", CollectorScheduleOverlay.NoRunAtText); + Assert.Equal(CollectorScheduleOverlay.UseDefaultRunAtText, new CollectorScheduleEditItem().RunAtText); + Assert.All(CollectorSchedulePresets.BuildDefaultSchedule(), i => Assert.Equal(CollectorScheduleOverlay.UseDefaultRunAtText, i.RunAtText)); + } + + [Fact] + public void TheCell_HoldsTheScopesOwnRunTime_NeverTheFleetOneOverlaidOnAServer() + { + var rows = new[] + { + new CollectorRunTimeRow(null, Daily, 120), + new CollectorRunTimeRow(2, Daily, -1), + new CollectorRunTimeRow(3, Daily, 195), + }; + + string Cell(int? serverId) => + CollectorScheduleOverlay.BuildEffectiveSchedule(Array.Empty(), rows, serverId).Single(i => i.Name == Daily).RunAtText; + + Assert.Equal("02:00", Cell(null)); + /* A server with no run-time row of its own shows "Use default", not the fleet's 02:00: if the cell took the fleet's + value, a save would write it into every server's run-time rows and "Use default" could never survive. */ + Assert.Equal("Use default", Cell(1)); + Assert.Equal("None", Cell(2)); + Assert.Equal("03:15", Cell(3)); + Assert.Equal("Use default", Cell(99)); + + Assert.All( + CollectorScheduleOverlay.BuildEffectiveSchedule(Array.Empty(), rows, 3).Where(i => i.Name != Daily), + i => Assert.Equal("Use default", i.RunAtText)); + } + + [Fact] + public void ARunTime_NeedsNoScheduleRow_AndAScheduleRowCarriesNone() + { + /* A run time lives in its own table, so a collector left at its default cadence with a run time writes a run-time + change and NO schedule row, and the schedule rows a Save writes name no run time at all. */ + Assert.Empty(CollectorScheduleOverlay.ToFleetOverrideRows(new[] { Item(Daily, "02:00") })); + + var change = Assert.Single(CollectorScheduleOverlay.ToRunTimeChanges( + new[] { Item(Daily, "02:00") }, Array.Empty(), serverId: null, usesDefault: false)); + Assert.Equal(new CollectorRunTimeChange(null, Daily, 120), change); + + var serverRows = CollectorScheduleOverlay.ToServerOverrideRows(new[] { Item(Daily, "02:00"), Item(AnotherDaily, "None") }, 5); + Assert.All(serverRows, r => Assert.Equal(5, r.ServerId)); + Assert.DoesNotContain( + typeof(CollectorScheduleRow).GetProperties(), p => p.Name.Contains("RunAt", StringComparison.Ordinal)); + } + + [Fact] + public void NoneOnTheFleet_IsNoRow_BecauseTheFleetHasNothingToStop() + { + var stored = new[] { new CollectorRunTimeRow(null, Daily, 120) }; + + /* A stored fleet time set to None is deleted, and None with nothing stored writes nothing. */ + var change = Assert.Single(CollectorScheduleOverlay.ToRunTimeChanges(new[] { Item(Daily, "None") }, stored, null, false)); + Assert.Equal(new CollectorRunTimeChange(null, Daily, null), change); + Assert.Empty(CollectorScheduleOverlay.ToRunTimeChanges(new[] { Item(Daily, "None") }, Array.Empty(), null, false)); + } + + [Fact] + public void TheRunTimeChanges_AreOnlyTheDifferences_AndNoneIsMinusOneOnAServer() + { + var stored = new[] + { + new CollectorRunTimeRow(5, Daily, 120), + new CollectorRunTimeRow(5, AnotherDaily, 300), + new CollectorRunTimeRow(5, "pg_column_stats", 60), + new CollectorRunTimeRow(6, "pg_index_bloat", 10), + new CollectorRunTimeRow(null, "pg_index_bloat", 20), + }; + + var changes = CollectorScheduleOverlay.ToRunTimeChanges( + new[] + { + Item(Daily, "02:00"), // unchanged: no change + Item(AnotherDaily, "Use default"), // had a row: delete it + Item("pg_column_stats", "None"), // 60 -> -1 + Item("pg_index_bloat", "23:59"), // no row for server 5: insert + }, + stored, 5, usesDefault: false); + + Assert.Equal( + new[] + { + new CollectorRunTimeChange(5, AnotherDaily, null), + new CollectorRunTimeChange(5, "pg_column_stats", -1), + new CollectorRunTimeChange(5, "pg_index_bloat", 23 * 60 + 59), + }, + changes); + + /* "Use the default schedule" deletes every run-time row the server has, and no other server's or the fleet's. */ + var reset = CollectorScheduleOverlay.ToRunTimeChanges(CollectorSchedulePresets.BuildDefaultSchedule(), stored, 5, usesDefault: true); + Assert.Equal(3, reset.Count); + Assert.All(reset, c => { Assert.Equal(5, c.ServerId); Assert.Null(c.RunAtMinute); }); + } + + [Fact] + public void AServerWithOnlyARunTimeRow_IsCustom_NotUsingTheDefault() + { + var runTimes = new[] { new CollectorRunTimeRow(7, Daily, 90) }; + Assert.True(CollectorScheduleOverlay.ServerHasOverride(Array.Empty(), runTimes, 7)); + Assert.False(CollectorScheduleOverlay.ServerHasOverride(Array.Empty(), runTimes, 8)); + Assert.False(CollectorScheduleOverlay.ServerHasOverride(Array.Empty(), 7)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Use default")] + [InlineData("use default")] + [InlineData("default")] + public void TryParseRunAt_ReadsUseDefaultAsNull(string text) + { + Assert.True(CollectorScheduleOverlay.TryParseRunAt(text, out var minute, out var error)); + Assert.Null(minute); + Assert.Equal("", error); + } + + [Theory] + [InlineData("None")] + [InlineData("none")] + [InlineData(" NONE ")] + public void TryParseRunAt_ReadsNoneAsMinusOne(string text) + { + Assert.True(CollectorScheduleOverlay.TryParseRunAt(text, out var minute, out _)); + Assert.Equal(-1, minute); + } + + [Theory] + [InlineData("00:00", 0)] + [InlineData("02:00", 120)] + [InlineData(" 02:00 ", 120)] + [InlineData("13:45", 825)] + [InlineData("23:59", 1439)] + public void TryParseRunAt_ReadsATwentyFourHourTime(string text, int expected) + { + Assert.True(CollectorScheduleOverlay.TryParseRunAt(text, out var minute, out var error)); + Assert.Equal(expected, minute); + Assert.Equal("", error); + } + + [Theory] + [InlineData("24:00")] + [InlineData("02:60")] + [InlineData("2:5")] + [InlineData("12:3")] + [InlineData("1200")] + [InlineData("02-00")] + [InlineData("02:00pm")] + [InlineData("-1:00")] + [InlineData("abc")] + [InlineData("nonsense")] + public void TryParseRunAt_RefusesAnythingElse_WithTheSharedText(string text) + { + Assert.False(CollectorScheduleOverlay.TryParseRunAt(text, out var minute, out var error)); + Assert.Null(minute); + Assert.Equal("Run at must be a 24-hour time from 00:00 to 23:59, such as 02:00.", error); + Assert.Equal(CollectorRunTime.InvalidRunAtMessage, error); + } + + [Theory] + [InlineData("24:00")] + [InlineData("2:5")] + [InlineData("noon")] + public void ASaveWithABadRunTime_IsRefused_NamingTheCollectorAndTheRule(string text) + { + Assert.False(Valid(Item(Daily, text), out var error)); + Assert.Contains(CollectorRunTime.InvalidRunAtMessage, error, StringComparison.Ordinal); + Assert.Contains($"'{Daily}'", error, StringComparison.Ordinal); + } + + [Theory] + [InlineData(1)] + [InlineData(60)] + [InlineData(1439)] + [InlineData(1441)] + [InlineData(2000)] + public void ARunTime_OnAnIntervalThatIsNotWholeDays_IsRefused_WithTheSharedWholeDayText(int minutes) + { + Assert.False(Valid(Item(Daily, "02:00", frequency: minutes), out var error)); + Assert.Contains(CollectorRunTime.IntervalRefusalMessage(Daily, minutes), error, StringComparison.Ordinal); + } + + [Fact] + public void TheHourlyRefusal_ReadsTheSharedSentence() + { + Assert.False(Valid(Item(Hourly, "02:00"), out var error)); + Assert.Contains( + "'wait_stats' runs every 1 minutes. A run time works only for a collector that runs once a day or less often " + + "(1440 minutes, or a multiple of 1440). Change the frequency or clear the run time.", + error, StringComparison.Ordinal); + } + + [Fact] + public void ARunTime_IsAllowedOnADailyCollector_AMultipleOfADay_AndAnOnLoadCollector() + { + Assert.True(Valid(Item(Daily, "02:00"), out _)); + Assert.True(Valid(Item(Daily, "02:00", frequency: 2880), out _)); + Assert.True(Valid(Item(Daily, "02:00", frequency: 10080), out _)); + /* An on-load collector (0) recaptures daily, so it is judged as a daily one. */ + Assert.True(Valid(Item(OnLoad, "02:00"), out _)); + } + + [Fact] + public void NoneAndUseDefault_AreAlwaysAllowed_EvenOnAnHourlyCollector_BecauseClearingIsTheRemedy() + { + Assert.True(Valid(Item(Hourly, "None"), out var error)); + Assert.Equal("", error); + Assert.True(Valid(Item(Hourly, "Use default"), out _)); + Assert.True(Valid(Item(Hourly, ""), out _)); + } + + /* ------------------------------ the conversion line ------------------------------ */ + + private static readonly DateTime Now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc); + + private const int ServerId = 41; + + private static string Describe( + string runAtText, int frequencyMinutes = 1440, int? serverId = ServerId, int? fleetRunAtMinute = null, + ServerClock? clock = null, bool azureSqlDatabase = false, string collector = Daily) => + CollectorScheduleRunAtText.Describe(collector, runAtText, frequencyMinutes, serverId, fleetRunAtMinute, clock, azureSqlDatabase, Now); + + [Fact] + public void AFleetRow_ShowsTheSixtyMinuteSpread() + { + var text = Describe("02:00", serverId: null, clock: null); + Assert.Contains("between 02:00 and 03:00", text, StringComparison.Ordinal); + Assert.Contains("server time", text, StringComparison.Ordinal); + + Assert.Contains("between 23:30 and 00:30", Describe("23:30", serverId: null), StringComparison.Ordinal); + } + + [Fact] + public void AFleetRowWithNoTime_SaysItHasNoFixedTime() + { + var text = Describe("Use default", serverId: null); + Assert.Contains("No fixed run time", text, StringComparison.Ordinal); + Assert.DoesNotContain("between", text, StringComparison.Ordinal); + } + + [Fact] + public void AServerRow_ShowsItsExactMinute_AndTheUtcConversion() + { + var clock = ServerClock.FixedOffset(-240); + var slot = CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), 120, ServerId, clock.ToUtc); + var local = clock.ToServerLocal(slot); + + var text = Describe("02:00", clock: clock); + + /* The same shape as the plan's example, "02:00 server time (06:00 UTC today)", with this server's own spread + added to the time: the slot is run time + CadencePhaseOffset(serverId, 3600). */ + Assert.Contains($"{local:HH:mm} server time ({slot:HH:mm} UTC today)", text, StringComparison.Ordinal); + Assert.Equal(CollectorCadence.CadencePhaseOffset(ServerId, 3600), CollectorRunTime.Spread(ServerId)); + } + + [Fact] + public void AServerRow_ShowsTheNextRun_ForADailyCollector() + { + var clock = ServerClock.FixedOffset(-240); + /* Now is 12:00 UTC, after today's slot (about 06:xx UTC), so the next run is tomorrow's. */ + var next = CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 3), 120, ServerId, clock.ToUtc); + + var text = Describe("02:00", clock: clock); + + Assert.Contains($"Next run: {next:yyyy-MM-dd HH:mm} UTC", text, StringComparison.Ordinal); + } + + [Fact] + public void NowBeforeTodaysSlot_PutsTheNextRunToday() + { + var clock = ServerClock.Utc; + var today = CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), 18 * 60, ServerId, clock.ToUtc); + + var text = Describe("18:00", clock: clock); + + Assert.Contains($"Next run: {today:yyyy-MM-dd HH:mm} UTC", text, StringComparison.Ordinal); + } + + [Fact] + public void AZoneAheadOfUtc_SaysTheUtcDayTheSlotFallsOn() + { + /* 02:00 at +10:00 is 16:00 UTC the day before. */ + var clock = ServerClock.FixedOffset(600); + var slot = CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), 120, ServerId, clock.ToUtc); + Assert.Equal(new DateTime(2026, 10, 1), slot.Date); + + Assert.Contains($"{slot:HH:mm} UTC yesterday", Describe("02:00", clock: clock), StringComparison.Ordinal); + } + + [Fact] + public void AServerWithNoClockYet_ReadsTheTimeAsUtc_AndSaysSo() + { + var text = Describe("02:00", clock: null); + + Assert.Contains("not known yet", text, StringComparison.Ordinal); + Assert.Contains("UTC", text, StringComparison.Ordinal); + var slot = CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), 120, ServerId, CollectorRunTime.LocalIsUtc); + Assert.Contains($"{slot:HH:mm} server time", text, StringComparison.Ordinal); + } + + [Fact] + public void AzureSqlDatabase_SaysItAlwaysReportsUtc() + { + var text = Describe("02:00", clock: ServerClock.Utc, azureSqlDatabase: true); + + Assert.Contains("Azure SQL Database always reports UTC", text, StringComparison.Ordinal); + } + + [Fact] + public void AServerRow_UsingTheFleetsTime_NamesTheFleetTime_AndStillShowsItsOwnMinute() + { + var clock = ServerClock.Utc; + var slot = CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), 120, ServerId, clock.ToUtc); + + var text = Describe("Use default", fleetRunAtMinute: 120, clock: clock); + + Assert.Contains("fleet-wide run time", text, StringComparison.Ordinal); + Assert.Contains($"{slot:HH:mm} server time", text, StringComparison.Ordinal); + } + + [Fact] + public void AServerRow_WithNoneOrNoTimeAnywhere_SaysThereIsNoFixedTime() + { + Assert.Contains("No fixed run time on this server", Describe("None", fleetRunAtMinute: 120), StringComparison.Ordinal); + Assert.DoesNotContain("Next run", Describe("None", fleetRunAtMinute: 120), StringComparison.Ordinal); + Assert.Contains("No fixed run time", Describe("Use default", fleetRunAtMinute: null), StringComparison.Ordinal); + } + + [Fact] + public void ATimeThatCannotApply_ShowsTheRefusalInsteadOfAConversion() + { + var text = Describe("02:00", frequencyMinutes: 60, collector: Hourly, clock: ServerClock.Utc); + + Assert.Contains(CollectorRunTime.IntervalRefusalMessage(Hourly, 60), text, StringComparison.Ordinal); + Assert.DoesNotContain("Next run", text, StringComparison.Ordinal); + } + + [Fact] + public void ABadTime_ShowsTheSharedMessage() + { + Assert.Equal(CollectorRunTime.InvalidRunAtMessage, Describe("25:61")); + } + + [Fact] + public void ACollectorThatRunsEveryFewDays_DoesNotClaimANextRunDate() + { + var text = Describe("02:00", frequencyMinutes: 2880, clock: ServerClock.Utc); + + Assert.Contains("every 2 days", text, StringComparison.Ordinal); + Assert.DoesNotContain("Next run:", text, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/CollectorScheduleEditorSaveGuardTests.cs b/Darling/Darling.Tests/CollectorScheduleEditorSaveGuardTests.cs new file mode 100644 index 0000000000..789080befe --- /dev/null +++ b/Darling/Darling.Tests/CollectorScheduleEditorSaveGuardTests.cs @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// A Save of the Collector Schedules window that follows a failed read (#4938): no successful read, no write. A failed read leaves +/// the window holding nothing for it, which looks the same as a store with nothing stored, so a Save must not write what the window +/// never saw. The Save's contents come from a pure plan (), tested here without +/// a window or a store, and source pins on the window check that it records a failed read, keeps its warning and sends the plan. +/// +[Trait("Stage", "Guard")] +public sealed class CollectorScheduleEditorSaveGuardTests +{ + private const string Daily = "index_object_stats"; + private const int ServerId = 41; + + /// The grid of a scope the window read nothing for: every cell the code default, every Run at cell "Use default". + private static List Grid() => CollectorSchedulePresets.BuildDefaultSchedule(); + + private static string Window() => CSharpSourceWalker.StripCommentsAndStrings( + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "CollectorScheduleEditorWindow.xaml.cs")); + + /// A server whose only override is a run-time row (set from the command line, say) opens on "Use default schedule" when + /// the run-time read fails, because the window then holds no run times. A plain Save of that window, nothing changed, must not + /// send the clear that "Use default schedule" otherwise sends: that clear deleted the run-time row it never showed. + [Fact] + public void ASave_AfterAFailedRunTimeRead_ThatChangedNothing_SendsNoRunTimeDeleteOrClear() + { + var plan = CollectorScheduleOverlay.BuildSavePlan( + Grid(), Array.Empty(), ServerId, usesDefault: true, resetToDefaults: false, schedulesRead: true, runTimesRead: false); + + Assert.Null(plan.Refusal); + Assert.Empty(plan.RunTimeChanges); + Assert.False(plan.ClearRunTimes); + Assert.Empty(plan.Rows); + } + + /// The control: the same window after a read that worked still clears the scope's run times on "Use default schedule", + /// which is what that checkbox means. The guard is for the unread case only. + [Fact] + public void ASave_AfterASuccessfulRead_OnUseDefaultSchedule_StillClearsTheServersRunTimes() + { + var plan = CollectorScheduleOverlay.BuildSavePlan( + Grid(), new[] { new CollectorRunTimeRow(ServerId, Daily, 300) }, ServerId, usesDefault: true, resetToDefaults: false, schedulesRead: true, runTimesRead: true); + + Assert.Null(plan.Refusal); + Assert.True(plan.ClearRunTimes); + } + + /// A server that does customize its schedule still saves it when the run-time read failed, and sends no run-time change + /// with it: not a delete, not an upsert, not a clear. + [Fact] + public void ASave_AfterAFailedRunTimeRead_StillSavesTheSchedule_WithNoRunTimeChange() + { + var grid = Grid(); + grid.Single(i => i.Name == "wait_stats").FrequencyMinutes = 10; + + var plan = CollectorScheduleOverlay.BuildSavePlan( + grid, Array.Empty(), ServerId, usesDefault: false, resetToDefaults: false, schedulesRead: true, runTimesRead: false); + + Assert.Null(plan.Refusal); + Assert.Contains(plan.Rows, r => r.CollectorName == "wait_stats" && r.FrequencyMinutes == 10); + Assert.Empty(plan.RunTimeChanges); + Assert.False(plan.ClearRunTimes); + } + + /// A run time typed into a cell, or a Reset to Defaults, is something the user asked for. With the run times unread it + /// cannot be written safely, so the Save is refused outright and says why, and sends nothing, instead of saving the rest and + /// quietly dropping that. + [Fact] + public void ASave_AfterAFailedRunTimeRead_IsRefused_WhenItWouldDropATypedRunTimeOrAReset() + { + var typed = Grid(); + typed.Single(i => i.Name == Daily).RunAtText = "02:00"; + + var withTime = CollectorScheduleOverlay.BuildSavePlan( + typed, Array.Empty(), ServerId, usesDefault: false, resetToDefaults: false, schedulesRead: true, runTimesRead: false); + var withReset = CollectorScheduleOverlay.BuildSavePlan( + Grid(), Array.Empty(), ServerId, usesDefault: false, resetToDefaults: true, schedulesRead: true, runTimesRead: false); + + foreach (var plan in new[] { withTime, withReset }) + { + Assert.Equal(CollectorScheduleOverlay.RunTimesUnreadRefusal, plan.Refusal); + Assert.Empty(plan.Rows); + Assert.Empty(plan.RunTimeChanges); + Assert.False(plan.ClearRunTimes); + } + } + + /// The schedule read has the same shape: a failed read leaves the window on the code defaults, and a Save of that + /// replaces the scope's stored rows with nothing. After a failed schedule read the Save is refused and sends nothing, for the + /// fleet scope and for a server alike. + [Theory] + [InlineData(null, false)] + [InlineData(ServerId, true)] + [InlineData(ServerId, false)] + public void ASave_AfterAFailedScheduleRead_IsRefused_AndSendsNothing(int? serverId, bool usesDefault) + { + var plan = CollectorScheduleOverlay.BuildSavePlan( + Grid(), Array.Empty(), serverId, usesDefault, resetToDefaults: false, schedulesRead: false, runTimesRead: true); + + Assert.Equal(CollectorScheduleOverlay.SchedulesUnreadRefusal, plan.Refusal); + Assert.Empty(plan.Rows); + Assert.Empty(plan.RunTimeChanges); + Assert.False(plan.ClearRunTimes); + } + + /// The window records a failed read, keeps its warning in front of every later status text, and builds its Save from + /// the plan. Its status line is written in ONE place, because any other assignment to it is what overwrote the warning. + [Fact] + public void TheWindow_KeepsAFailedReadsWarningVisible_AndBuildsItsSaveFromThePlan() + { + var window = Window(); + + Assert.Single(Regex.Matches(window, @"StatusText\.Text = ")); + Assert.Contains("CollectorScheduleOverlay.BuildSavePlan(", window, StringComparison.Ordinal); + Assert.Contains("schedulesRead: SchedulesRead", window, StringComparison.Ordinal); + Assert.Contains("runTimesRead: RunTimesRead", window, StringComparison.Ordinal); + Assert.Contains(".SaveCollectorScheduleAsync(_scopeServerId, plan.Rows, plan.RunTimeChanges, plan.ClearRunTimes)", window, StringComparison.Ordinal); + + /* Both reads set a warning of their own when they fail, and the same reads clear it when they work. */ + var runTimeRead = window[window.IndexOf("private async Task ReloadRunTimesAsync(", StringComparison.Ordinal)..window.IndexOf("private void ShowStatus(", StringComparison.Ordinal)]; + Assert.Contains("_runTimeReadWarning = ", runTimeRead, StringComparison.Ordinal); + var scheduleRead = window[window.IndexOf("private async Task ReloadSchedulesAsync(", StringComparison.Ordinal)..window.IndexOf("private async Task ReloadRunTimesAsync(", StringComparison.Ordinal)]; + Assert.Contains("_scheduleReadWarning = ", scheduleRead, StringComparison.Ordinal); + + /* "Apply Default to All Servers" re-reads through the same two steps, so a re-read that fails guards the next Save too. */ + var applyAll = window[window.IndexOf("private async void ApplyDefaultToAll_Click(", StringComparison.Ordinal)..window.IndexOf("private void CancelButton_Click(", StringComparison.Ordinal)]; + Assert.Contains("await ReloadSchedulesAsync();", applyAll, StringComparison.Ordinal); + Assert.Contains("await ReloadRunTimesAsync();", applyAll, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/CollectorScheduleResetStatusTests.cs b/Darling/Darling.Tests/CollectorScheduleResetStatusTests.cs new file mode 100644 index 0000000000..a6efd2539b --- /dev/null +++ b/Darling/Darling.Tests/CollectorScheduleResetStatusTests.cs @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// The status line after "Apply Default to All Servers" (#4938): the per-server schedule overrides and the per-server run times the +/// call removed are counted apart and each named, singular or plural, so a reset of run times alone no longer reads as a reset of +/// schedule overrides. The store's own counts are checked in . +/// +public sealed class CollectorScheduleResetStatusTests +{ + private const string ResetTail = " — every server now follows the fleet default."; + + [Theory] + [InlineData(0, 0, "No per-server overrides to reset — every server already follows the fleet default.")] + [InlineData(1, 0, "Reset 1 per-server schedule override" + ResetTail)] + [InlineData(3, 0, "Reset 3 per-server schedule overrides" + ResetTail)] + [InlineData(0, 1, "Reset 1 per-server run time" + ResetTail)] + [InlineData(0, 2, "Reset 2 per-server run times" + ResetTail)] + [InlineData(1, 2, "Reset 1 per-server schedule override and 2 per-server run times" + ResetTail)] + [InlineData(2, 1, "Reset 2 per-server schedule overrides and 1 per-server run time" + ResetTail)] + public void TheResetStatus_CountsScheduleOverridesAndRunTimesApart_AndNamesEach(int scheduleOverrides, int runTimes, string expected) + { + Assert.Equal(expected, CollectorScheduleOverlay.FormatResetStatus(new CollectorScheduleResetCounts(scheduleOverrides, runTimes))); + } + + /// The window shows the text the formatter builds from the call's two counts, and no longer one number for both. + [Fact] + public void TheWindow_ShowsTheCountedResetStatus_NotOneNumberForBothKinds() + { + var source = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "CollectorScheduleEditorWindow.xaml.cs"); + + Assert.Contains("CollectorScheduleOverlay.FormatResetStatus(removed)", source, StringComparison.Ordinal); + Assert.DoesNotContain("schedule override(s)", source, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/CollectorScheduleUseDefaultRunAtLineTests.cs b/Darling/Darling.Tests/CollectorScheduleUseDefaultRunAtLineTests.cs new file mode 100644 index 0000000000..fcdf64f8b3 --- /dev/null +++ b/Darling/Darling.Tests/CollectorScheduleUseDefaultRunAtLineTests.cs @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Linq; +using PerformanceMonitor.Analysis.Baselines; +using PerformanceMonitor.Collectors; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// The Run at line under the Collector Schedules grid, for a server that is on "Use default schedule" (#4938). The grid then shows +/// the FLEET's schedule, so the selected row's Run at cell holds the fleet's own time, and the line used to describe that as a +/// time the server had of its own. It now says the server uses the fleet-wide run time, the same sentence as for a server row set to +/// "Use default". All of it is pure, so it is tested without a window. +/// +public sealed class CollectorScheduleUseDefaultRunAtLineTests +{ + private const string Daily = "index_object_stats"; + private const int ServerId = 41; + private static readonly DateTime Now = new(2026, 10, 2, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void UnderUseDefaultSchedule_TheRunAtLine_SaysTheServerUsesTheFleetWideRunTime() + { + var fleetGrid = CollectorScheduleOverlay.BuildEffectiveSchedule( + Array.Empty(), new[] { new CollectorRunTimeRow(null, Daily, 120) }, null); + var cell = fleetGrid.Single(i => i.Name == Daily); + Assert.Equal("02:00", cell.RunAtText); + + var line = CollectorScheduleRunAtText.Describe( + Daily, cell.RunAtText, cell.FrequencyMinutes, ServerId, 120, ServerClock.Utc, false, Now, usesDefaultSchedule: true); + var slot = CollectorRunTime.SlotUtc(new DateOnly(2026, 10, 2), 120, ServerId, ServerClock.Utc.ToUtc); + + Assert.StartsWith("Uses the fleet-wide run time (02:00). ", line, StringComparison.Ordinal); + Assert.Contains($"{slot:HH:mm} server time", line, StringComparison.Ordinal); + + /* The same cell on a server that customizes its schedule is that server's own time, and says nothing about the fleet. */ + var own = CollectorScheduleRunAtText.Describe( + Daily, cell.RunAtText, cell.FrequencyMinutes, ServerId, 120, ServerClock.Utc, false, Now); + Assert.DoesNotContain("fleet-wide", own, StringComparison.Ordinal); + } + + [Fact] + public void UnderUseDefaultSchedule_WithNoFleetWideRunTime_TheRunAtLine_SaysThereIsNoFixedTime() + { + var line = CollectorScheduleRunAtText.Describe( + Daily, "Use default", 1440, ServerId, null, ServerClock.Utc, false, Now, usesDefaultSchedule: true); + + Assert.StartsWith("No fixed run time", line, StringComparison.Ordinal); + Assert.DoesNotContain("fleet-wide", line, StringComparison.Ordinal); + } + + /// The fleet scope has no "Use default schedule", so the flag changes nothing on a fleet row. + [Fact] + public void TheFleetRow_IgnoresUseDefaultSchedule() + { + var plain = CollectorScheduleRunAtText.Describe(Daily, "02:00", 1440, null, 120, ServerClock.Utc, false, Now); + var flagged = CollectorScheduleRunAtText.Describe(Daily, "02:00", 1440, null, 120, ServerClock.Utc, false, Now, usesDefaultSchedule: true); + + Assert.Equal(plain, flagged); + } + + [Fact] + public void TheWindow_PassesUseDefaultScheduleToTheRunAtLine() + { + var window = CSharpSourceWalker.StripCommentsAndStrings( + RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Viewer", "CollectorScheduleEditorWindow.xaml.cs")); + var refresh = window[window.IndexOf("private void RefreshRunAtDetail(", StringComparison.Ordinal)..window.IndexOf("private void RebuildEditingForScope(", StringComparison.Ordinal)]; + + Assert.Contains("usesDefaultSchedule:", refresh, StringComparison.Ordinal); + Assert.Contains("UseDefaultCheckBox.IsChecked == true", refresh, StringComparison.Ordinal); + } +} diff --git a/Darling/Darling.Tests/CollectorStallProbeStoreTests.cs b/Darling/Darling.Tests/CollectorStallProbeStoreTests.cs index b7dab3f540..518a24a0ca 100644 --- a/Darling/Darling.Tests/CollectorStallProbeStoreTests.cs +++ b/Darling/Darling.Tests/CollectorStallProbeStoreTests.cs @@ -266,6 +266,19 @@ throughput half of the firing condition would be inert. */ Assert.Contains("using var stallProbeArm = StallProbeArm.Start(", runner, StringComparison.Ordinal); } + /// + /// The probe query matches this service's own session by APP_NAME() and HOST_NAME(), so + /// it binds no parameters: the runner must send the text as it ships, with none attached. + /// + [Fact] + public void TheProbeQuery_IsSentWithoutParameters() + { + var runner = ReadSource("Darling/PerformanceMonitor.Darling.Service/StallWaitProbeRunner.cs"); + + Assert.Contains("new CollectorQuery(StallWaitProbePolicy.QueryText)", runner, StringComparison.Ordinal); + Assert.DoesNotContain("new CollectorQuery(StallWaitProbePolicy.QueryText,", runner, StringComparison.Ordinal); + } + private static string RungSql() => PgMigrations.Scripts.Single(s => s.Version == RungVersion).Sql; private static bool IsTriggerSideColumn(string name) => diff --git a/Darling/Darling.Tests/CollectorStateContractTests.cs b/Darling/Darling.Tests/CollectorStateContractTests.cs index c58bd990ba..6349844003 100644 --- a/Darling/Darling.Tests/CollectorStateContractTests.cs +++ b/Darling/Darling.Tests/CollectorStateContractTests.cs @@ -38,6 +38,7 @@ namespace Darling.Tests; /// at source, the idiom this suite already uses for cross-artifact contracts. /// [Collection("live-postgres")] +[Trait("Reads", "Lite")] public sealed class CollectorStateContractTests { private const string RepoRootNotFound = "repo root not found -- the source pin cannot run"; diff --git a/Darling/Darling.Tests/ColumnFilterPersistenceTests.cs b/Darling/Darling.Tests/ColumnFilterPersistenceTests.cs new file mode 100644 index 0000000000..443612500d --- /dev/null +++ b/Darling/Darling.Tests/ColumnFilterPersistenceTests.cs @@ -0,0 +1,827 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Windows.Controls; +using System.Windows.Data; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// Column filters kept across a refresh and a restart (#5565): the store file (round trip, per-server separation, +/// least-recently-used bound, a bad file ignored, a cleared filter removed, no row data), and the grid manager that +/// loads them on the first scoped refresh, keeps them across UpdateData, and clears them on "Clear all filters". +/// +public sealed class ColumnFilterPersistenceTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "colfilter-" + Guid.NewGuid().ToString("N")); + private string FilePath => Path.Combine(_dir, "column-filters.json"); + + public ColumnFilterPersistenceTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + ColumnFilterStore.Current = null; + try { Directory.Delete(_dir, true); } catch (IOException) { } + } + + private ColumnFilterStore NewStore(List? warnings = null) => + new(FilePath, warnings is null ? null : warnings.Add, TimeSpan.FromHours(1)); + + private static ColumnFilterState Hide(string column, params string[] values) + { + var state = new ColumnFilterState { ColumnName = column, ValueMode = ColumnValueMode.Hide }; + foreach (var v in values) state.Values.Add(v); + return state; + } + + [Fact] + public void A_filter_saved_comes_back_after_a_restart_with_its_mode_values_and_text_match() + { + var first = NewStore(); + var saved = Hide("LoginName", "job_svc", "NT AUTHORITY\\SYSTEM"); + saved.ValueBlank = true; + saved.Operator = FilterOperator.StartsWith; + saved.Value = "sa"; + first.Save("srv-a", "QuerySnapshotsGrid", new[] { saved }); + first.Flush(); + + var restarted = NewStore(); + var loaded = Assert.Single(restarted.Load("srv-a", "QuerySnapshotsGrid")); + + Assert.Equal("LoginName", loaded.ColumnName); + Assert.Equal(ColumnValueMode.Hide, loaded.ValueMode); + Assert.True(loaded.Values.SetEquals(new[] { "job_svc", "NT AUTHORITY\\SYSTEM" })); + Assert.True(loaded.ValueBlank); + Assert.Equal(FilterOperator.StartsWith, loaded.Operator); + Assert.Equal("sa", loaded.Value); + } + + [Fact] + public void Filters_are_kept_apart_per_server_and_per_grid() + { + var store = NewStore(); + store.Save("srv-a", "GridOne", new[] { Hide("LoginName", "a") }); + store.Save("srv-b", "GridOne", new[] { Hide("LoginName", "b") }); + store.Save("srv-a", "GridTwo", new[] { Hide("LoginName", "c") }); + store.Flush(); + + var restarted = NewStore(); + Assert.Equal("a", Assert.Single(restarted.Load("srv-a", "GridOne")).Values.Single()); + Assert.Equal("b", Assert.Single(restarted.Load("srv-b", "GridOne")).Values.Single()); + Assert.Equal("c", Assert.Single(restarted.Load("srv-a", "GridTwo")).Values.Single()); + Assert.Empty(restarted.Load("srv-c", "GridOne")); + } + + [Fact] + public void A_cleared_filter_is_removed_from_the_file() + { + var store = NewStore(); + store.Save("srv-a", "GridOne", new[] { Hide("LoginName", "a") }); + store.Flush(); + store.Save("srv-a", "GridOne", new[] { new ColumnFilterState { ColumnName = "LoginName" } }); + store.Flush(); + + Assert.Empty(NewStore().Load("srv-a", "GridOne")); + Assert.DoesNotContain("srv-a", File.ReadAllText(FilePath), StringComparison.Ordinal); + } + + [Fact] + public void Only_the_200_most_recently_used_grids_are_kept() + { + var store = NewStore(); + for (var i = 0; i < 205; i++) + store.Save("srv", "Grid" + i, new[] { Hide("c", "v") }); + store.Load("srv", "Grid0"); // not evicted yet? it was: the first five went when 201..205 arrived + store.Flush(); + + var restarted = NewStore(); + Assert.Empty(restarted.Load("srv", "Grid0")); + Assert.Empty(restarted.Load("srv", "Grid4")); + Assert.Single(restarted.Load("srv", "Grid5")); + Assert.Single(restarted.Load("srv", "Grid204")); + } + + [Fact] + public void Using_a_grid_keeps_it_when_older_ones_are_dropped() + { + var store = NewStore(); + for (var i = 0; i < 200; i++) + store.Save("srv", "Grid" + i, new[] { Hide("c", "v") }); + store.Load("srv", "Grid0"); // the oldest becomes the newest + store.Save("srv", "Extra", new[] { Hide("c", "v") }); // 201st: Grid1 goes, not Grid0 + store.Flush(); + + var restarted = NewStore(); + Assert.Single(restarted.Load("srv", "Grid0")); + Assert.Empty(restarted.Load("srv", "Grid1")); + Assert.Single(restarted.Load("srv", "Extra")); + } + + [Fact] + public void A_column_naming_more_than_1000_values_keeps_its_text_match_and_loses_only_the_value_part() + { + var store = NewStore(); + var big = Hide("Big", Enumerable.Range(0, 1001).Select(i => "v" + i).ToArray()); + big.ValueBlank = true; + big.Value = "v1"; + var bigNoText = Hide("BigNoText", Enumerable.Range(0, 1001).Select(i => "v" + i).ToArray()); + var edge = Hide("Edge", Enumerable.Range(0, 1000).Select(i => "v" + i).ToArray()); + store.Save("srv", "Grid", new[] { big, bigNoText, edge }); + store.Flush(); + + var loaded = NewStore().Load("srv", "Grid"); + + /* the web page does the same: a cut set would change what it hides, but the text match fits and stays */ + Assert.Equal(new[] { "Big", "Edge" }, loaded.Select(f => f.ColumnName).OrderBy(n => n)); + var kept = loaded.Single(f => f.ColumnName == "Big"); + Assert.Equal(ColumnValueMode.None, kept.ValueMode); + Assert.Empty(kept.Values); + Assert.False(kept.ValueBlank); + Assert.Equal("v1", kept.Value); + Assert.Equal(1000, loaded.Single(f => f.ColumnName == "Edge").Values.Count); + } + + [Fact] + public void A_text_match_on_a_column_that_gets_no_list_is_never_written_but_one_on_a_list_column_is() + { + var store = NewStore(); + var statement = new ColumnFilterState { ColumnName = "QueryText", Value = "salary > 90000" }; + var script = new ColumnFilterState { ColumnName = "ImplementationScript", Value = "force_plan" }; + var error = new ColumnFilterState { ColumnName = "LastError", Value = "login failed for" }; + var login = new ColumnFilterState { ColumnName = "LoginName", Value = "svc_" }; + store.Save("srv", "Grid", new[] { statement, script, error, login }); + store.Flush(); + + var text = File.ReadAllText(FilePath); + Assert.DoesNotContain("salary", text, StringComparison.Ordinal); + Assert.DoesNotContain("force_plan", text, StringComparison.Ordinal); + Assert.DoesNotContain("login failed", text, StringComparison.Ordinal); + Assert.Equal("LoginName", Assert.Single(NewStore().Load("srv", "Grid")).ColumnName); + + /* and one planted in the file is not loaded either */ + File.WriteAllText(FilePath, "{\"Version\":1,\"Grids\":[{\"Server\":\"s\",\"Grid\":\"g\",\"Columns\":[" + + "{\"Column\":\"QueryText\",\"Operator\":\"Contains\",\"Value\":\"x\"},{\"Column\":\"LoginName\",\"Operator\":\"Contains\",\"Value\":\"y\"}]}]}"); + Assert.Equal("LoginName", Assert.Single(NewStore().Load("s", "g")).ColumnName); + } + + [Fact] + public void The_stored_limits_apply_when_the_file_is_read_too() + { + var many = string.Join(",", Enumerable.Range(0, 1001).Select(i => "\"v" + i + "\"")); + var longValue = new string('x', ColumnFilterStore.MaxValueLength + 1); + var grids = new List + { + /* too many values: the value part goes, the text match stays */ + "{\"Server\":\"s\",\"Grid\":\"many\",\"Columns\":[{\"Column\":\"LoginName\",\"Operator\":\"Contains\",\"Value\":\"t\",\"ValueMode\":\"Hide\",\"Values\":[" + many + "]}]}", + /* a value too long: the same */ + "{\"Server\":\"s\",\"Grid\":\"longvalue\",\"Columns\":[{\"Column\":\"LoginName\",\"Value\":\"t\",\"ValueMode\":\"Hide\",\"Values\":[\"" + longValue + "\"]}]}", + /* a text match too long: it goes, the value part stays */ + "{\"Server\":\"s\",\"Grid\":\"longtext\",\"Columns\":[{\"Column\":\"LoginName\",\"Value\":\"" + longValue + "\",\"ValueMode\":\"Hide\",\"Values\":[\"a\"]}]}", + /* a mode and an operator this version does not know: not match-all */ + "{\"Server\":\"s\",\"Grid\":\"badenum\",\"Columns\":[{\"Column\":\"LoginName\",\"Operator\":\"99\",\"ValueMode\":\"99\",\"Values\":[\"a\"]}]}", + /* a column name that is too long */ + "{\"Server\":\"s\",\"Grid\":\"longcolumn\",\"Columns\":[{\"Column\":\"" + longValue + "\",\"Value\":\"t\"}]}", + }; + File.WriteAllText(FilePath, "{\"Version\":1,\"Grids\":[" + string.Join(",", grids) + "]}"); + var warnings = new List(); + var store = NewStore(warnings); + + var many1 = Assert.Single(store.Load("s", "many")); + Assert.Equal(ColumnValueMode.None, many1.ValueMode); + Assert.Equal("t", many1.Value); + var longv = Assert.Single(store.Load("s", "longvalue")); + Assert.Equal(ColumnValueMode.None, longv.ValueMode); + Assert.Equal("t", longv.Value); + var longt = Assert.Single(store.Load("s", "longtext")); + Assert.Equal("", longt.Value); + Assert.Equal(ColumnValueMode.Hide, longt.ValueMode); + Assert.Empty(store.Load("s", "badenum")); + Assert.Empty(store.Load("s", "longcolumn")); + Assert.Empty(warnings); + } + + [Fact] + public void A_file_with_more_than_200_grids_or_200_columns_keeps_only_the_newest_grids_and_the_first_columns() + { + var grids = Enumerable.Range(0, 300).Select(i => + "{\"Server\":\"s\",\"Grid\":\"g" + i + "\",\"Columns\":[{\"Column\":\"LoginName\",\"Value\":\"t\"}]}"); + File.WriteAllText(FilePath, "{\"Version\":1,\"Grids\":[" + string.Join(",", grids) + "]}"); + var store = NewStore(); + + Assert.Empty(store.Load("s", "g99")); + Assert.Single(store.Load("s", "g100")); + Assert.Single(store.Load("s", "g299")); + + var columns = string.Join(",", Enumerable.Range(0, 250).Select(i => "{\"Column\":\"C" + i + "\",\"Value\":\"t\"}")); + File.WriteAllText(FilePath, "{\"Version\":1,\"Grids\":[{\"Server\":\"s\",\"Grid\":\"wide\",\"Columns\":[" + columns + "]}]}"); + Assert.Equal(ColumnFilterStore.MaxColumnsPerGrid, NewStore().Load("s", "wide").Count); + } + + [Fact] + public void A_null_or_wrong_typed_entry_is_skipped_and_the_rest_of_the_file_is_used() + { + File.WriteAllText(FilePath, "{\"Version\":1,\"Grids\":[null,5,\"x\",[1]," + + "{\"Server\":\"s\",\"Grid\":\"g\",\"Columns\":[null,7,\"x\",{\"Column\":5},{\"Column\":\"LoginName\",\"Value\":\"ok\",\"Values\":[null,1]}," + + "{\"Column\":\"HostName\",\"Value\":\"h\",\"ValueMode\":\"Hide\",\"Values\":[\"a\",null]}]}," + + "{\"Server\":5,\"Grid\":\"g2\",\"Columns\":[]},{\"Server\":\"s\",\"Grid\":\"g3\",\"Columns\":null}]}"); + var warnings = new List(); + var store = NewStore(warnings); + + var loaded = store.Load("s", "g"); + Assert.Equal(new[] { "HostName", "LoginName" }, loaded.Select(f => f.ColumnName).OrderBy(n => n)); + Assert.Equal("ok", loaded.Single(f => f.ColumnName == "LoginName").Value); + /* a null among the values makes that value part unusable, not the column */ + Assert.Equal(ColumnValueMode.None, loaded.Single(f => f.ColumnName == "HostName").ValueMode); + Assert.Empty(store.Load("s", "g2")); + Assert.Empty(store.Load("s", "g3")); + Assert.Empty(warnings); + } + + [Fact] + public void A_file_over_the_size_limit_or_nested_too_deep_is_ignored_whole_with_one_warning() + { + using (var stream = new FileStream(FilePath, FileMode.Create)) + stream.SetLength(ColumnFilterStore.MaxFileBytes + 1); + var warnings = new List(); + var big = NewStore(warnings); + Assert.Empty(big.Load("s", "g")); + Assert.Empty(big.Load("s", "g")); + Assert.Single(warnings); + + File.WriteAllText(FilePath, "{\"Version\":1,\"Grids\":" + new string('[', 100) + new string(']', 100) + "}"); + warnings.Clear(); + var deep = NewStore(warnings); + Assert.Empty(deep.Load("s", "g")); + Assert.Single(warnings); + } + + [Fact] + public void A_failed_save_is_retried_on_the_next_tick_and_warned_once_per_run() + { + var warnings = new List(); + var store = new ColumnFilterStore(FilePath, warnings.Add, TimeSpan.FromMilliseconds(50)); + Directory.CreateDirectory(FilePath + ".tmp"); // the temp file cannot be written + store.Save("srv", "Grid", new[] { Hide("c", "v") }); + + store.Flush(); + store.Flush(); + store.Flush(); + Assert.Single(warnings); + Assert.False(File.Exists(FilePath)); + + Directory.Delete(FilePath + ".tmp"); // the cause goes away: the debounce timer's own tick writes the file + var deadline = DateTime.UtcNow.AddSeconds(15); + while (!File.Exists(FilePath) && DateTime.UtcNow < deadline) + Thread.Sleep(50); + + Assert.True(File.Exists(FilePath), "the failed save was never retried"); + Assert.Single(warnings); + Assert.Single(NewStore().Load("srv", "Grid")); + store.Dispose(); + } + + [Fact] + public void A_save_that_keeps_failing_backs_off_from_the_debounce_up_to_a_minute_and_a_good_save_resets_it() + { + var failing = true; + var warnings = new List(); + var store = new ColumnFilterStore(FilePath, warnings.Add, TimeSpan.FromSeconds(10), + (path, text) => { if (failing) throw new IOException("the disk is full"); File.WriteAllText(path, text); }); + store.Save("srv", "Grid", new[] { Hide("c", "v") }); + Assert.Equal(TimeSpan.FromSeconds(10), store.RetryDelay); + + var seen = new List(); + for (var i = 0; i < 6; i++) + { + store.Flush(); + seen.Add(store.RetryDelay.TotalSeconds); + } + + // 10 s after the first failure, then doubling, never more than 60 s. + Assert.Equal(new[] { 10d, 20d, 40d, 60d, 60d, 60d }, seen); + Assert.Single(warnings); + Assert.Contains("is tried again in 10 seconds", warnings[0], StringComparison.Ordinal); + Assert.Contains("up to 60 seconds", warnings[0], StringComparison.Ordinal); + Assert.DoesNotContain("each change", warnings[0], StringComparison.Ordinal); + + failing = false; + store.Flush(); + Assert.True(File.Exists(FilePath)); + Assert.Equal(TimeSpan.FromSeconds(10), store.RetryDelay); + store.Dispose(); + } + + [Fact] + public void A_slow_write_does_not_hold_up_Load_or_Save() + { + using var writing = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + var store = new ColumnFilterStore(FilePath, null, TimeSpan.FromHours(1), (path, text) => + { + writing.Set(); + release.Wait(TimeSpan.FromSeconds(30)); + File.WriteAllText(path, text); + }); + store.Save("srv", "Grid", new[] { Hide("c", "v") }); + var flush = new Thread(store.Flush) { IsBackground = true }; + flush.Start(); + Assert.True(writing.Wait(TimeSpan.FromSeconds(15)), "the write never started"); + + // The file I/O is outside the store's lock: a grid's Load and Save on the UI thread do not wait for it. + var loadedCount = -1; + var meanwhile = new Thread(() => + { + loadedCount = store.Load("srv", "Grid").Count; + store.Save("srv", "Other", new[] { Hide("d", "w") }); + }) { IsBackground = true }; + meanwhile.Start(); + var finished = meanwhile.Join(TimeSpan.FromSeconds(5)); + release.Set(); + flush.Join(TimeSpan.FromSeconds(15)); + Assert.True(finished, "Load and Save waited for the file write"); + Assert.Equal(1, loadedCount); + + // The change made during the write is not lost: the store is still dirty, and the next flush writes it. + store.Flush(); + Assert.Single(NewStore().Load("srv", "Other")); + store.Dispose(); + } + + [Fact] + public void The_same_server_and_grid_twice_in_a_file_keeps_the_last_copy_so_Load_and_Save_agree() + { + File.WriteAllText(FilePath, "{\"Version\":1,\"Grids\":[" + + "{\"Server\":\"s\",\"Grid\":\"g\",\"Columns\":[{\"Column\":\"LoginName\",\"Value\":\"old\"}]}," + + "{\"Server\":\"s\",\"Grid\":\"other\",\"Columns\":[{\"Column\":\"HostName\",\"Value\":\"h\"}]}," + + "{\"Server\":\"s\",\"Grid\":\"g\",\"Columns\":[{\"Column\":\"LoginName\",\"Value\":\"first\"}," + + "{\"Column\":\"HostName\",\"Value\":\"h2\"},{\"Column\":\"LoginName\",\"Value\":\"last\"}]}]}"); + var store = NewStore(); + + var loaded = store.Load("s", "g"); + Assert.Equal(2, loaded.Count); + Assert.Equal("last", loaded.Single(f => f.ColumnName == "LoginName").Value); + Assert.Equal("h2", loaded.Single(f => f.ColumnName == "HostName").Value); + + // Saving the grid replaces the one entry: the older copy does not come back on the next read. + store.Save("s", "g", Array.Empty()); + store.Flush(); + var again = NewStore(); + Assert.Empty(again.Load("s", "g")); + Assert.Single(again.Load("s", "other")); + store.Dispose(); + } + + [Fact] + public void A_write_goes_through_a_temp_file_so_a_failed_write_leaves_the_old_file_whole() + { + var warnings = new List(); + var store = NewStore(warnings); + store.Save("srv", "One", new[] { Hide("c", "v") }); + store.Flush(); + var before = File.ReadAllBytes(FilePath); + + /* the temp file cannot be written: a write straight into the real file would change it, replacing the temp file does not */ + Directory.CreateDirectory(FilePath + ".tmp"); + store.Save("srv", "Two", new[] { Hide("c", "w") }); + store.Flush(); + + Assert.Single(warnings); + Assert.Equal(before, File.ReadAllBytes(FilePath)); + Assert.Single(NewStore().Load("srv", "One")); + Assert.Empty(NewStore().Load("srv", "Two")); + } + + [Fact] + public void A_file_left_in_the_old_place_is_moved_to_the_new_one_once_and_deleted() + { + var legacy = Path.Combine(_dir, "roaming", "column-filters.json"); + Directory.CreateDirectory(Path.GetDirectoryName(legacy)!); + var seed = NewStore(); + seed.Save("srv", "Grid", new[] { Hide("c", "v") }); + seed.Flush(); + File.Move(FilePath, legacy); + + var target = Path.Combine(_dir, "local", "column-filters.json"); + ColumnFilterStore.Install(target, null, legacy); + try + { + Assert.False(File.Exists(legacy)); + Assert.True(File.Exists(target)); + Assert.Single(ColumnFilterStore.Current!.Load("srv", "Grid")); + } + finally + { + ColumnFilterStore.Current = null; + } + + /* when the new place already has a file, the old one is only deleted */ + File.WriteAllText(legacy, "{\"Version\":1,\"Grids\":[]}"); + ColumnFilterStore.MigrateLegacyFile(legacy, target); + Assert.False(File.Exists(legacy)); + Assert.True(File.Exists(target)); + } + + [Theory] + [InlineData("this is not json {")] + [InlineData("{\"version\": 99, \"grids\": []}")] + [InlineData("[1,2,3]")] + [InlineData("")] + public void A_file_that_cannot_be_read_is_reported_once_and_ignored(string content) + { + File.WriteAllText(FilePath, content); + var warnings = new List(); + var store = NewStore(warnings); + + Assert.Empty(store.Load("srv", "Grid")); + Assert.Empty(store.Load("srv", "Grid")); + store.Save("srv", "Grid", new[] { Hide("c", "v") }); + Assert.Single(store.Load("srv", "Grid")); + Assert.Single(warnings); + } + + [Fact] + public void A_missing_file_is_a_first_run_and_says_nothing() + { + var warnings = new List(); + Assert.Empty(NewStore(warnings).Load("srv", "Grid")); + Assert.Empty(warnings); + } + + [Fact] + public void The_file_holds_the_ticked_values_and_the_keys_and_no_row_data() + { + var store = NewStore(); + store.Save("srv-a", "QuerySnapshotsGrid", new[] { Hide("LoginName", "job_svc") }); + store.Flush(); + + var text = File.ReadAllText(FilePath); + Assert.Contains("job_svc", text, StringComparison.Ordinal); + Assert.Contains("QuerySnapshotsGrid", text, StringComparison.Ordinal); + Assert.False(File.Exists(FilePath + ".tmp"), "the temp file is replaced away"); + } + + [Fact] + public void Writes_are_debounced_until_the_flush() + { + var store = NewStore(); // an hour's debounce + store.Save("srv", "Grid", new[] { Hide("c", "v") }); + + Assert.False(File.Exists(FilePath)); + store.Flush(); + Assert.True(File.Exists(FilePath)); + } + + [Fact] + public void Writes_replace_the_old_file_in_one_step() + { + var store = NewStore(); + store.Save("srv", "One", new[] { Hide("c", "v") }); + store.Flush(); + store.Save("srv", "Two", new[] { Hide("c", "v") }); + store.Flush(); + + var restarted = NewStore(); + Assert.Single(restarted.Load("srv", "One")); + Assert.Single(restarted.Load("srv", "Two")); + Assert.False(File.Exists(FilePath + ".tmp")); + } + + // ---- the grid manager ------------------------------------------------- + + private sealed class LoginRow + { + public string? LoginName { get; set; } + public int SessionId { get; set; } + public string? QueryText { get; set; } + public string? Detail { get; set; } + } + + private static DataGrid NewGrid(string? name, string? server) + { + var grid = new DataGrid { AutoGenerateColumns = false }; + if (name is not null) grid.Name = name; + if (server is not null) ColumnFilterScope.SetServer(grid, server); + foreach (var path in new[] { "LoginName", "SessionId", "QueryText" }) + grid.Columns.Add(new DataGridTextColumn { Header = path, Binding = new Binding(path) }); + return grid; + } + + private static List Rows(params string?[] logins) => + logins.Select((l, i) => new LoginRow { LoginName = l, SessionId = i }).ToList(); + + private static List Shown(DataGrid grid) => + grid.Items.OfType().Select(r => r.LoginName).ToList(); + + private static void Untick(IDataGridFilterManager manager, string column, string value) + { + var model = new ColumnValueListModel(manager.GetValueCatalog(column)!, manager.Filters.GetValueOrDefault(column)); + model.Entries.Single(e => e.Value == value).IsTicked = false; + var state = new ColumnFilterState { ColumnName = column }; + model.ApplyTo(state); + manager.SetFilter(state); + } + + [Fact] + public void A_text_match_on_a_query_text_column_lasts_the_session_but_is_not_stored() + { + var kept = StaTestThread.Run(() => + { + var store = NewStore(); + ColumnFilterStore.Current = store; + var grid = NewGrid("QuerySnapshotsGrid", "srv-a"); + var manager = new DataGridFilterManager(grid); + var rows = new List + { + new() { LoginName = "sa", QueryText = "select salary from pay" }, + new() { LoginName = "app", QueryText = "select 1" }, + }; + manager.UpdateData(rows); + manager.SetFilter(new ColumnFilterState { ColumnName = "QueryText", Value = "salary" }); + manager.SetFilter(new ColumnFilterState { ColumnName = "LoginName", Value = "sa" }); + + /* a refresh keeps both for the session */ + manager.UpdateData(rows); + var afterRefresh = Shown(grid); + store.Flush(); + store.Dispose(); + + var restarted = NewStore(); + ColumnFilterStore.Current = restarted; + var again = new DataGridFilterManager(NewGrid("QuerySnapshotsGrid", "srv-a")); + again.UpdateData(rows); + return (afterRefresh, again.Filters.Keys.OrderBy(k => k).ToList()); + }); + + Assert.Equal(new string?[] { "sa" }, kept.afterRefresh); + Assert.Equal(new[] { "LoginName" }, kept.Item2); + Assert.DoesNotContain("salary", File.ReadAllText(FilePath), StringComparison.Ordinal); + } + + [Fact] + public void A_filter_survives_a_refresh_and_a_value_that_first_appears_later_still_shows() + { + StaTestThread.Run(() => + { + var grid = NewGrid("G", null); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", "app", "job_svc")); + Untick(manager, "LoginName", "job_svc"); + + Assert.Equal(new string?[] { "sa", "app" }, Shown(grid)); + + manager.UpdateData(Rows("sa", "app", "job_svc", "newuser")); + Assert.Equal(new string?[] { "sa", "app", "newuser" }, Shown(grid)); + Assert.True(manager.AnyFilterActive); + return true; + }); + } + + [Fact] + public void The_value_list_comes_from_the_unfiltered_rows_not_the_ones_shown() + { + StaTestThread.Run(() => + { + var grid = NewGrid("G", null); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", "app", "job_svc")); + Untick(manager, "LoginName", "job_svc"); + + var catalog = manager.GetValueCatalog("LoginName")!; + Assert.Equal(new[] { "app", "job_svc", "sa" }, catalog.Values); + return true; + }); + } + + [Fact] + public void Number_query_text_and_prose_columns_and_columns_the_row_lacks_get_no_list() + { + StaTestThread.Run(() => + { + var grid = NewGrid("G", null); + var manager = new DataGridFilterManager(grid); + Assert.Null(manager.GetValueCatalog("LoginName")); // no rows yet + manager.UpdateData(Rows("sa")); + + Assert.NotNull(manager.GetValueCatalog("LoginName")); + Assert.Null(manager.GetValueCatalog("SessionId")); + Assert.Null(manager.GetValueCatalog("QueryText")); + Assert.Null(manager.GetValueCatalog("Detail")); + Assert.Null(manager.GetValueCatalog("NoSuchColumn")); + return true; + }); + } + + [Fact] + public void A_text_column_that_sorts_by_another_member_gets_no_list() + { + StaTestThread.Run(() => + { + var grid = NewGrid("G", null); + ((DataGridTextColumn)grid.Columns[0]).SortMemberPath = "SessionId"; + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa")); + + Assert.Null(manager.GetValueCatalog("LoginName")); + return true; + }); + } + + [Fact] + public void A_column_with_a_value_over_256_characters_gets_no_list() + { + StaTestThread.Run(() => + { + var grid = NewGrid("G", null); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", new string('x', 257))); + + Assert.Null(manager.GetValueCatalog("LoginName")); + return true; + }); + } + + [Fact] + public void A_filter_set_in_one_session_is_loaded_by_the_next_on_its_first_refresh() + { + var shown = StaTestThread.Run(() => + { + var store = NewStore(); + ColumnFilterStore.Current = store; + var grid = NewGrid("QuerySnapshotsGrid", "srv-a"); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", "app", "job_svc")); + Untick(manager, "LoginName", "job_svc"); + store.Flush(); + store.Dispose(); + + /* a restart: a new store over the same file, a new grid, a new manager */ + var restarted = NewStore(); + ColumnFilterStore.Current = restarted; + var grid2 = NewGrid("QuerySnapshotsGrid", "srv-a"); + var manager2 = new DataGridFilterManager(grid2); + manager2.UpdateData(Rows("sa", "app", "job_svc", "newuser")); + return Shown(grid2); + }); + + Assert.Equal(new string?[] { "sa", "app", "newuser" }, shown); + } + + [Fact] + public void A_cross_server_list_under_the_all_servers_scope_keeps_its_filters_across_a_restart() + { + var shown = StaTestThread.Run(() => + { + var store = NewStore(); + ColumnFilterStore.Current = store; + var manager = new DataGridFilterManager(NewGrid("AlertsDataGrid", ColumnFilterScope.AllServers)); + manager.UpdateData(Rows("sa", "app", "job_svc")); + Untick(manager, "LoginName", "job_svc"); + store.Flush(); + store.Dispose(); + + ColumnFilterStore.Current = NewStore(); + var grid = NewGrid("AlertsDataGrid", ColumnFilterScope.AllServers); + new DataGridFilterManager(grid).UpdateData(Rows("sa", "app", "job_svc")); + return Shown(grid); + }); + + Assert.Equal(new string?[] { "sa", "app" }, shown); + Assert.StartsWith("\u0001", ColumnFilterScope.AllServers, StringComparison.Ordinal); + } + + [Fact] + public void Another_servers_filters_do_not_apply_and_a_grid_without_a_name_or_scope_is_not_stored() + { + StaTestThread.Run(() => + { + var store = NewStore(); + ColumnFilterStore.Current = store; + + var grid = NewGrid("G", "srv-a"); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", "job_svc")); + Untick(manager, "LoginName", "job_svc"); + + var other = NewGrid("G", "srv-b"); + var otherManager = new DataGridFilterManager(other); + otherManager.UpdateData(Rows("sa", "job_svc")); + Assert.Equal(new string?[] { "sa", "job_svc" }, Shown(other)); + + var unnamed = NewGrid(null, "srv-a"); + var unnamedManager = new DataGridFilterManager(unnamed); + unnamedManager.UpdateData(Rows("sa", "job_svc")); + Untick(unnamedManager, "LoginName", "job_svc"); + + var unscoped = NewGrid("G", null); + var unscopedManager = new DataGridFilterManager(unscoped); + unscopedManager.UpdateData(Rows("sa", "job_svc")); + Untick(unscopedManager, "LoginName", "job_svc"); + + Assert.Single(store.Load("srv-a", "G")); + Assert.Empty(store.Load("srv-a", "")); + return true; + }); + } + + [Fact] + public void A_stored_filter_on_a_column_the_grid_no_longer_has_is_ignored() + { + StaTestThread.Run(() => + { + var store = NewStore(); + store.Save("srv-a", "G", new[] { Hide("RetiredColumn", "x"), Hide("LoginName", "job_svc") }); + ColumnFilterStore.Current = store; + + var grid = NewGrid("G", "srv-a"); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", "job_svc")); + + Assert.Equal(new[] { "LoginName" }, manager.Filters.Keys); + Assert.Equal(new string?[] { "sa" }, Shown(grid)); + return true; + }); + } + + [Fact] + public void Clear_all_filters_clears_the_grid_and_the_stored_copy_but_a_server_switch_does_not() + { + StaTestThread.Run(() => + { + var store = NewStore(); + ColumnFilterStore.Current = store; + var grid = NewGrid("G", "srv-a"); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", "job_svc")); + Untick(manager, "LoginName", "job_svc"); + + manager.ClearFilters(); // #2306: a context change + Assert.Equal(2, Shown(grid).Count); + Assert.Single(store.Load("srv-a", "G")); // still stored for that server + + manager.UpdateData(Rows("sa", "job_svc")); // the next refresh loads what is stored for the scope + Assert.Equal(new string?[] { "sa" }, Shown(grid)); + + manager.ClearAllFilters(); // the user + Assert.Equal(2, Shown(grid).Count); + Assert.False(manager.AnyFilterActive); + Assert.Empty(store.Load("srv-a", "G")); + + manager.UpdateData(Rows("sa", "job_svc")); + Assert.Equal(2, Shown(grid).Count); + return true; + }); + } + + [Fact] + public void A_server_switch_loads_the_new_servers_own_filters() + { + StaTestThread.Run(() => + { + var store = NewStore(); + store.Save("srv-b", "G", new[] { Hide("LoginName", "sa") }); + ColumnFilterStore.Current = store; + var grid = NewGrid("G", "srv-a"); + var manager = new DataGridFilterManager(grid); + manager.UpdateData(Rows("sa", "job_svc")); + Assert.Equal(2, Shown(grid).Count); + + ColumnFilterScope.SetServer(grid, "srv-b"); // the FinOps selector changes server + manager.ClearFilters(); + manager.UpdateData(Rows("sa", "job_svc")); + + Assert.Equal(new string?[] { "job_svc" }, Shown(grid)); + return true; + }); + } + + [Fact] + public void A_store_that_fails_never_blocks_the_grid() + { + StaTestThread.Run(() => + { + File.WriteAllText(FilePath, "garbage"); + ColumnFilterStore.Current = NewStore(); + var grid = NewGrid("G", "srv-a"); + var manager = new DataGridFilterManager(grid); + + manager.UpdateData(Rows("sa", "job_svc")); + Untick(manager, "LoginName", "job_svc"); + + Assert.Equal(new string?[] { "sa" }, Shown(grid)); + return true; + }); + } +} diff --git a/Darling/Darling.Tests/ColumnFilterPopupTests.cs b/Darling/Darling.Tests/ColumnFilterPopupTests.cs new file mode 100644 index 0000000000..0c0f2385a4 --- /dev/null +++ b/Darling/Darling.Tests/ColumnFilterPopupTests.cs @@ -0,0 +1,323 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using PerformanceMonitor.Common; +using PerformanceMonitor.Ui; +using Xunit; + +namespace Darling.Tests; + +/// +/// The column filter popup's value list (#5565) as the controls show it: the list section appears only for a column +/// with a catalog, search narrows it, Select All and the entries' ticks reach the applied filter, the notes, and +/// "Clear all filters" shows only when the grid has a filter and raises its own event. +/// +public sealed class ColumnFilterPopupTests +{ + private static T Find(DependencyObject root, Func where) where T : DependencyObject + { + foreach (var child in LogicalTreeHelper.GetChildren(root).OfType()) + { + if (child is T hit && where(hit)) + return hit; + try { return Find(child, where); } catch (InvalidOperationException) { } + } + throw new InvalidOperationException("not found"); + } + + private static Button ButtonNamed(ColumnFilterPopup popup, string content) => + Find