();
var sites = 0;
+ /* The end of the member a construction sits in: the next closing brace at member indentation (4 spaces).
+ Each site is judged on its own text, so a second context in a file that already ensures a variable of the
+ same name cannot borrow the first one's call. */
+ var memberEnd = new Regex(@"\n \}[ \t]*\n", RegexOptions.CultureInvariant);
+
foreach (var file in ProductSources(root))
{
- var text = File.ReadAllText(file);
+ var text = File.ReadAllText(file).Replace("\r\n", "\n");
foreach (Match m in construction.Matches(text))
{
sites++;
var close = text.IndexOf("};", m.Index, StringComparison.Ordinal);
var initializer = close < 0 ? text[m.Index..] : text[m.Index..close];
+ var end = memberEnd.Match(text, m.Index);
+ var member = end.Success ? text[m.Index..end.Index] : text[m.Index..];
var name = m.Groups["name"].Value;
var inInitializer = initializer.Contains("SecondaryReplicaDatabases", StringComparison.Ordinal);
- var ensured = name.Length > 0 && Regex.IsMatch(text, @"SecondaryReplicaScope\.EnsureAsync\([^)]*\b" + Regex.Escape(name) + @"\b");
+ var ensured = name.Length > 0 && Regex.IsMatch(member, @"SecondaryReplicaScope\.EnsureAsync\([^)]*\b" + Regex.Escape(name) + @"\b");
+
+ /* A context handed straight to AnalyzeAsync(context) is resolved by that overload, provided the overload
+ itself ensures the set it receives (checked on the same file's declaration, so the hand-off cannot
+ be a loophole). */
+ if (!inInitializer && !ensured && name.Length > 0 && Regex.IsMatch(member, @"\bAnalyzeAsync\(\s*" + Regex.Escape(name) + @"\s*\)"))
+ {
+ var overload = Regex.Match(text, @"AnalyzeAsync\(AnalysisContext (?\w+)\)");
+ if (overload.Success)
+ {
+ var overloadEnd = memberEnd.Match(text, overload.Index);
+ var body = overloadEnd.Success ? text[overload.Index..overloadEnd.Index] : text[overload.Index..];
+ ensured = Regex.IsMatch(body, @"SecondaryReplicaScope\.EnsureAsync\([^)]*\b" + overload.Groups["p"].Value + @"\b");
+ }
+ }
if (!inInitializer && !ensured)
{
var line = text[..m.Index].Count(c => c == '\n') + 1;
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
index f43edad276..17d423dbcc 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
@@ -221,6 +221,15 @@ internal sealed record AnalysisEngineSet(
/// See : of those facts, how many scored above zero.
public int? LastFactsScored { get; private set; }
+ ///
+ /// #5558: the one-sentence note naming how many databases the last pass left to the primary replica because this
+ /// node holds only a secondary copy of them in an availability group, or null when none were skipped (and when
+ /// the pass never reached the set). Carried out like , because a short or empty
+ /// findings list cannot say it: without the note, "nothing found" reads as a clean bill of health for databases
+ /// the pass never looked at.
+ ///
+ public string? LastSecondaryReplicaNote { get; private set; }
+
///
/// How the last pass ended EARLY, or null when it ran through (#2430). Set inside the pass's own
/// catch, so here means a genuine fault: the pass reached the
@@ -386,6 +395,7 @@ public async Task> AnalyzeAsync(AnalysisContext context)
LastFactCount = null;
LastFactsScored = null;
EndedEarlyAs = null;
+ LastSecondaryReplicaNote = AgReplicaScope.SkippedNote(context.SecondaryReplicaDatabases); /* #5558 */
try
{
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs
index 6ea618299f..7698a1f499 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgDrillDownCollector.Queries.cs
@@ -819,6 +819,7 @@ FROM v_query_store_stats
-- matched independently, which admits every pairing of the listed databases and query ids: a superset
-- of the offenders, never a subset.
AND ($5::text[] IS NULL OR (database_name = ANY($5::text[]) AND query_id = ANY($6::bigint[])))
+ /*SEC*/
),
plan_dedup AS
(
@@ -877,6 +878,7 @@ FROM query_store_interval_latest
AND collection_time >= $7
AND first_execution_time >= $7
AND ($5::text[] IS NULL OR (database_name = ANY($5::text[]) AND query_id = ANY($6::bigint[])))
+ /*SEC*/
GROUP BY database_name, query_id, replica_role, query_plan_hash
HAVING SUM(execution_count) >= 25
),
@@ -1043,15 +1045,17 @@ test on each row (#4821). */
cmd.Parameters.AddWithValue(windowStart.AddDays(-1));
}
+ /* #5558: the same databases the PLAN_REGRESSION fact skipped, for the pass where the fact did not run and the
+ offender list above is NULL. The predicate sits in the source CTE, ahead of the LIMIT 5, so the five rows are
+ the worst five of the databases this node reports (Lite's twin does the same); a filter after the LIMIT could
+ leave fewer. Bound last, so its parameter number follows every parameter above. */
+ PgSecondaryReplicaScope.Apply(cmd, context, "PLAN_REGRESSION", "database_name");
+
ServerClock? clock = null;
var items = new List
public static class AgReplicaScope
{
From 3d184f977f6d937b3d916beaac96d9a5cfdfe706 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 8 Oct 2026 10:53:56 -0400
Subject: [PATCH 7/9] Reads traits on the new scope test classes; Darling
secondary-set read yields to an abandoned budget (#5558)
---
.../Darling.Tests/SecondaryReplicaScopeTests.cs | 3 +++
.../DarlingAnalysisService.cs | 16 ++++++++++++++--
Lite.Tests/SecondaryReplicaScopeTests.cs | 3 +++
3 files changed, 20 insertions(+), 2 deletions(-)
diff --git a/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs b/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs
index 7532705e45..8ab587c030 100644
--- a/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs
+++ b/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs
@@ -27,6 +27,7 @@ namespace Darling.Tests;
/// #5558: 's predicate builder and its fail-open read, with no database. The seeded
/// twins of Lite's fact tests are in .
///
+[Trait("Reads", "Lite")]
public sealed class PgSecondaryReplicaScopeTests
{
private static AnalysisContext ContextWith(params string[] secondaries) => new()
@@ -138,6 +139,7 @@ public void NoneSkipped_IsEmpty()
/// secondary copy would quietly come back for that one pass. A pass that reads only node-local facts says so by setting the
/// empty set on purpose. Scans Lite and Darling; test projects build contexts freely and are not scanned.
///
+[Trait("Reads", "Lite")]
public sealed class AnalysisContextSecondaryScopeSourceScanTests
{
private static string RepoRoot([CallerFilePath] string thisFile = "")
@@ -217,6 +219,7 @@ itself ensures the set it receives (checked on the same file's declaration, so t
}
/// #5558: the viewer Recommendations view model carries the note only where a list or the all-clear shows.
+[Trait("Reads", "Lite")]
public sealed class RecommendationsReplicaNoteTests
{
[Fact]
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
index 17d423dbcc..5b6a5ec717 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
@@ -280,8 +280,20 @@ internal sealed record AnalysisEngineSet(
/// so the replicated facts skip them. Every context this service builds goes through here. Fails open: any unknown
/// returns an empty set.
///
- internal Task> SecondaryScopeForAsync(int serverId, DateTime windowEndUtc, CancellationToken cancellationToken) =>
- PgSecondaryReplicaScope.ReadAsync(_postgres, serverId, windowEndUtc, _logger, cancellationToken);
+ internal async Task> SecondaryScopeForAsync(int serverId, DateTime windowEndUtc, CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await PgSecondaryReplicaScope.ReadAsync(_postgres, serverId, windowEndUtc, _logger, cancellationToken);
+ }
+ catch (Exception ex) when (AnalysisShutdown.IsExpectedAbandon(ex, cancellationToken))
+ {
+ /* This runs in the context initializer, ahead of the pass's own try. An abandoned budget must not escape the
+ wrapper from here: an empty set lets the pass reach its first checkpoint and take the normal abandon
+ path (and the read-only wrappers fail on their next store read, as #4203 requires). */
+ return PgSecondaryReplicaScope.NoneSkipped;
+ }
+ }
/// The store, read as whatever role this data source connects as.
/// Optional; the SQL Server drill-down's cached-plan fetch.
diff --git a/Lite.Tests/SecondaryReplicaScopeTests.cs b/Lite.Tests/SecondaryReplicaScopeTests.cs
index a65c825f6e..c5f008e616 100644
--- a/Lite.Tests/SecondaryReplicaScopeTests.cs
+++ b/Lite.Tests/SecondaryReplicaScopeTests.cs
@@ -26,6 +26,7 @@ namespace PerformanceMonitorLite.Tests;
/// #5558: the pure rule that names the databases this node holds only as a secondary copy. Every fail-open case
/// is pinned: skipping a database wrongly hides a finding, so anything unknown skips nothing.
///
+[Trait("Reads", "Darling")]
public sealed class AgReplicaScopeTests
{
private static readonly DateTime End = new(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc);
@@ -129,6 +130,7 @@ public void TheNote_IsOneSentence_ForOneAndMany_AndNullForNone()
}
/// #5558: the table that classifies facts, and the census that keeps it complete.
+[Trait("Reads", "Darling")]
public sealed class FactReplicaScopeCoverageTests
{
[Theory]
@@ -204,6 +206,7 @@ private static string FindRepoRoot()
/// the node-local ones keep it. Three databases: SecDb (local secondary), PrimDb (local primary of a
/// second group) and StandDb (not in a group).
///
+[Trait("Reads", "Darling")]
public sealed class SecondaryReplicaFactTests : IClassFixture
{
private const int ServerId = -558_001;
From 3fad4590a2c968c944bb25c4f14a04d9505ed0ed Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:14:12 -0400
Subject: [PATCH 8/9] get_analysis_facts carries secondary_replica_note on Lite
and Darling, always present and null when nothing was skipped (#5558)
---
.../SecondaryReplicaScopeLiveTests.cs | 44 +++++++++++++++++++
.../Mcp/DarlingMcpTools.cs | 22 ++++++----
.../SecondaryReplicaMcpAnalysisTests.cs | 38 ++++++++++++++++
Lite/Mcp/McpAnalysisTools.cs | 20 ++++++---
4 files changed, 109 insertions(+), 15 deletions(-)
diff --git a/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs b/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs
index f89ffd41fa..2614bf6de7 100644
--- a/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs
+++ b/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs
@@ -432,6 +432,50 @@ await WithStoreAsync(async (c, postgres, ct) =>
});
}
+ ///
+ /// Round 2: get_analysis_facts returns facts the secondary filter already trimmed, so it carries the same note, in
+ /// every envelope it can answer with, always present and null when nothing is skipped. Lite's twin is in
+ /// SecondaryReplicaMcpAnalysisTests.
+ ///
+ [Fact]
+ public async Task GetAnalysisFacts_CarriesTheNote_OnlyWhenThisNodeHoldsASecondaryCopy()
+ {
+ await WithStoreAsync(async (c, postgres, ct) =>
+ {
+ var service = new DarlingAnalysisService(postgres) { MinimumDataHours = 0 };
+
+ /* Nothing collected, no AG rows: an unavailable answer, the field there and null. */
+ Assert.Null(NoteOf(await DarlingMcpTools.GetAnalysisFacts(service, postgres, ServerName, 4, cancellationToken: ct)));
+
+ /* A secondary copy, still nothing collected: the sentence is in the field and in the prose. */
+ await SeedAgAsync(c, ct, Now().AddMinutes(-1));
+ var bare = await DarlingMcpTools.GetAnalysisFacts(service, postgres, ServerName, 4, cancellationToken: ct);
+ Assert.Equal(AgReplicaScope.SkippedNote(1), NoteOf(bare));
+ using (var bareDoc = JsonDocument.Parse(bare))
+ Assert.Contains(AgReplicaScope.SkippedNote(1)!, bareDoc.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal);
+
+ /* Facts in the window: the facts payload carries it at the root. */
+ for (var m = 5; m <= 55; m += 10) await PlantWaitAsync(c, ct, Now().AddMinutes(-m), 150_000L);
+ var withFacts = await DarlingMcpTools.GetAnalysisFacts(service, postgres, ServerName, 4, cancellationToken: ct);
+ using var factsDoc = JsonDocument.Parse(withFacts);
+ Assert.True(factsDoc.RootElement.TryGetProperty("facts", out _), "expected the facts payload, got: " + withFacts[..Math.Min(withFacts.Length, 200)]);
+ Assert.Equal(AgReplicaScope.SkippedNote(1), factsDoc.RootElement.GetProperty("secondary_replica_note").GetString());
+ });
+ }
+
+ [Fact]
+ public async Task GetAnalysisFacts_OnAPrimary_CarriesANullNote()
+ {
+ await WithStoreAsync(async (c, postgres, ct) =>
+ {
+ await SeedAgAsync(c, ct, Now().AddMinutes(-1), localRole: "PRIMARY");
+ for (var m = 5; m <= 55; m += 10) await PlantWaitAsync(c, ct, Now().AddMinutes(-m), 150_000L);
+ var answer = await DarlingMcpTools.GetAnalysisFacts(
+ new DarlingAnalysisService(postgres) { MinimumDataHours = 0 }, postgres, ServerName, 4, cancellationToken: ct);
+ Assert.Null(NoteOf(answer));
+ });
+ }
+
/// M4 (round 1): compare_analysis carries one note per window, each from the role at that window's own end.
[Fact]
public async Task CompareAnalysis_CarriesOneNotePerWindow_EachFromItsOwnRole()
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs
index 13b519d4ba..b78bfc9336 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs
@@ -340,6 +340,12 @@ public static async Task GetAnalysisFacts(
var (facts, coverage, collection) = await analysisService.CollectAndScoreFactsAsync(
resolved.ServerId, resolved.ServerName, hours_back, asOfUtc: anchor, cancellationToken: cancellationToken);
+ /* #5558: databases left to the primary because this node holds only a secondary copy of them in an
+ availability group, as of the window end; the facts below were trimmed of them. Read like
+ compare_analysis and get_analysis_findings read it, not through the service's last-pass state, so two
+ concurrent reads cannot swap notes. Null when none, and then the field is simply null. */
+ var secondaryReplicaNote = await PgSecondaryReplicaScope.NoteAsync(postgres, resolved.ServerId, null, cancellationToken, anchor);
+
/* #3691: null on a clean read, and then every envelope below is byte-for-byte what it was; when
a family failed, the sentence is appended to the message and collection_caveats to the payload. */
var collectionCaveat = collection.Describe();
@@ -351,12 +357,11 @@ public static async Task GetAnalysisFacts(
#3691: a pass in which every family FAILED lands here too, and used to be told apart from
"no data" only by the service log; the caveat now says which families were not read. */
const string noFacts = "No facts collected. The collector may not have run yet, or no data exists in the requested time range.";
- return collectionCaveat is null
- ? McpHelpers.Status("unavailable", noFacts)
- : McpHelpers.Status(
- "unavailable",
- $"{noFacts} COLLECTION CAVEAT: {collectionCaveat}",
- collection.Attach(new { coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
+ var noFactsMessage = secondaryReplicaNote is null ? noFacts : $"{noFacts} {secondaryReplicaNote}";
+ return McpHelpers.Status(
+ "unavailable",
+ collectionCaveat is null ? noFactsMessage : $"{noFactsMessage} COLLECTION CAVEAT: {collectionCaveat}",
+ collection.Attach(new { secondary_replica_note = secondaryReplicaNote, coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
}
if (coverage is null || !coverage.IsObserved)
@@ -373,7 +378,7 @@ after configuration alone knows audit_config still has it. */
$"{facts.Count} point-in-time fact(s) — configuration and current state — could still be read; audit_config reports those. " +
"Check get_collection_health to see when collectors last succeeded and why they stopped." +
(collectionCaveat is null ? string.Empty : $" COLLECTION CAVEAT: {collectionCaveat}"),
- collection.Attach(new { coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
+ collection.Attach(new { secondary_replica_note = secondaryReplicaNote, coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
}
var filtered = facts.AsEnumerable();
@@ -425,6 +430,7 @@ after configuration alone knows audit_config still has it. */
total_facts = facts.Count,
shown = result.Count,
filters = new { source, min_severity },
+ secondary_replica_note = secondaryReplicaNote, /* #5558: null when nothing was left to the primary */
/* #3538 A2: null at full coverage; below the partial bar it says what share of the window
the fractions and rates were divided over, because a 25%-of-observed-time wait on a
quarter-collected window is a different claim from 25% of four hours. #3691: the
@@ -1147,7 +1153,7 @@ exactly when the window held an occurrence the newest-first LIMIT dropped. This
/* #5558: databases skipped because this node holds only a secondary copy of them in an availability group
(their per-database findings are the primary's). Null when none; the same sentence on every surface. */
- var secondaryReplicaNote = await PgSecondaryReplicaScope.NoteAsync(postgres, resolved.ServerId, logger, cancellationToken, anchor);
+ var secondaryReplicaNote = await PgSecondaryReplicaScope.NoteAsync(postgres, resolved.ServerId, null, cancellationToken, anchor);
if (findings.Count == 0)
{
diff --git a/Lite.Tests/SecondaryReplicaMcpAnalysisTests.cs b/Lite.Tests/SecondaryReplicaMcpAnalysisTests.cs
index ed5753c09a..47172c3ddc 100644
--- a/Lite.Tests/SecondaryReplicaMcpAnalysisTests.cs
+++ b/Lite.Tests/SecondaryReplicaMcpAnalysisTests.cs
@@ -140,6 +140,44 @@ public async Task AnalyzeServer_OnAPrimary_CarriesANullNote()
Assert.Null(service.LastSecondaryReplicaNote);
}
+ ///
+ /// #5558 round 2: get_analysis_facts returns facts the secondary filter already trimmed, so it carries the same
+ /// field, always present and null when nothing was left to the primary, in every envelope it can answer with (no facts,
+ /// facts without an observed window, the facts payload).
+ ///
+ [Fact]
+ public async Task GetAnalysisFacts_CarriesTheNote_OnlyWhenThisNodeHoldsASecondaryCopy()
+ {
+ var service = CreateService();
+
+ /* Nothing collected, no AG rows: an unavailable answer, the field there and null. */
+ Assert.Null(NoteOf(await McpAnalysisTools.GetAnalysisFacts(service, _serverManager, null, 4)));
+
+ /* A secondary copy, still nothing collected: the sentence is in the field and in the prose. */
+ await SeedAgAsync(DateTime.UtcNow.AddMinutes(-1), "SECONDARY");
+ var bare = await McpAnalysisTools.GetAnalysisFacts(service, _serverManager, null, 4);
+ Assert.Equal(AgReplicaScope.SkippedNote(1), NoteOf(bare));
+ using (var bareDoc = JsonDocument.Parse(bare))
+ Assert.Contains(AgReplicaScope.SkippedNote(1)!, bareDoc.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal);
+
+ /* Facts in the window: the facts payload carries it at the root. */
+ for (var m = 5; m <= 55; m += 10) await PlantWaitAsync(DateTime.UtcNow.AddMinutes(-m), 150_000L);
+ var withFacts = await McpAnalysisTools.GetAnalysisFacts(service, _serverManager, null, 4);
+ using (var factsDoc = JsonDocument.Parse(withFacts))
+ {
+ Assert.True(factsDoc.RootElement.TryGetProperty("facts", out _), "expected the facts payload, got: " + withFacts[..Math.Min(withFacts.Length, 200)]);
+ Assert.Equal(AgReplicaScope.SkippedNote(1), factsDoc.RootElement.GetProperty("secondary_replica_note").GetString());
+ }
+ }
+
+ [Fact]
+ public async Task GetAnalysisFacts_OnAPrimary_CarriesANullNote()
+ {
+ await SeedAgAsync(DateTime.UtcNow.AddMinutes(-1), "PRIMARY");
+ for (var m = 5; m <= 55; m += 10) await PlantWaitAsync(DateTime.UtcNow.AddMinutes(-m), 150_000L);
+ Assert.Null(NoteOf(await McpAnalysisTools.GetAnalysisFacts(CreateService(), _serverManager, null, 4)));
+ }
+
[Fact]
public async Task CompareAnalysis_CarriesOneNotePerWindow_EachFromItsOwnRole()
{
diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs
index 7983e5ce40..d710ac1063 100644
--- a/Lite/Mcp/McpAnalysisTools.cs
+++ b/Lite/Mcp/McpAnalysisTools.cs
@@ -306,6 +306,12 @@ public static async Task GetAnalysisFacts(
var (facts, coverage, collection) = await analysisService.CollectAndScoreFactsAsync(
resolved.ServerId, resolved.ServerName, hours_back, asOfUtc: anchor, cancellationToken);
+ /* #5558: databases left to the primary because this node holds only a secondary copy of them in an
+ availability group, as of the window end; the facts below were trimmed of them. Read like
+ compare_analysis and get_analysis_findings read it, not through the service's last-pass state, so two
+ concurrent reads cannot swap notes. Null when none, and then the field is simply null. */
+ var secondaryReplicaNote = await analysisService.GetSecondaryReplicaNoteAsync(resolved.ServerId, anchor, cancellationToken);
+
/* #3691: null on a clean read, and then every envelope below is byte-for-byte what it was; when
a family failed, the sentence is appended to the message and collection_caveats to the payload. */
var collectionCaveat = collection.Describe();
@@ -317,12 +323,11 @@ public static async Task GetAnalysisFacts(
#3691: a pass in which every family FAILED lands here too, and used to be told apart from
"no data" only by the service log; the caveat now says which families were not read. */
const string noFacts = "No facts collected. The collector may not have run yet, or no data exists in the requested time range.";
- return collectionCaveat is null
- ? McpHelpers.Status("unavailable", noFacts)
- : McpHelpers.Status(
- "unavailable",
- $"{noFacts} COLLECTION CAVEAT: {collectionCaveat}",
- collection.Attach(new { coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
+ var noFactsMessage = secondaryReplicaNote is null ? noFacts : $"{noFacts} {secondaryReplicaNote}";
+ return McpHelpers.Status(
+ "unavailable",
+ collectionCaveat is null ? noFactsMessage : $"{noFactsMessage} COLLECTION CAVEAT: {collectionCaveat}",
+ collection.Attach(new { secondary_replica_note = secondaryReplicaNote, coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
}
if (coverage is null || !coverage.IsObserved)
@@ -339,7 +344,7 @@ after configuration alone knows audit_config still has it. */
$"{facts.Count} point-in-time fact(s) — configuration and current state — could still be read; audit_config reports those. " +
"Check get_collection_health to see when collectors last succeeded and why they stopped." +
(collectionCaveat is null ? string.Empty : $" COLLECTION CAVEAT: {collectionCaveat}"),
- collection.Attach(new { coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
+ collection.Attach(new { secondary_replica_note = secondaryReplicaNote, coverage = coverage?.ToPayload() }, McpHelpers.JsonOptions));
}
var filtered = facts.AsEnumerable();
@@ -391,6 +396,7 @@ after configuration alone knows audit_config still has it. */
total_facts = facts.Count,
shown = result.Count,
filters = new { source, min_severity },
+ secondary_replica_note = secondaryReplicaNote, /* #5558: null when nothing was left to the primary */
/* #3538 A2: null at full coverage; below the partial bar it says what share of the window
the fractions and rates were divided over, because a 25%-of-observed-time wait on a
quarter-collected window is a different claim from 25% of four hours. #3691: the
From 5e8649487587ba22a45d1bb50da6a101d981027e Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 8 Oct 2026 11:42:54 -0400
Subject: [PATCH 9/9] PR #5564 round 2: notes come from the set the read
filtered with, database names match exactly, scan bounded to its member, both
drill-down variants pinned (#5558)
---
...ecommendationsSecondaryReplicaLiveTests.cs | 6 +-
...eAnalysisServiceCancellationSourceTests.cs | 2 +-
.../SecondaryReplicaScopeLiveTests.cs | 27 ++++++++
.../SecondaryReplicaScopeTests.cs | 62 +++++++++++++++----
.../DarlingAnalysisService.cs | 14 +++--
.../PgSecondaryReplicaScope.cs | 10 +--
.../Mcp/DarlingMcpTools.cs | 17 ++---
Lite.Tests/FinOpsSecondaryReplicaTests.cs | 9 ++-
Lite.Tests/SecondaryReplicaScopeTests.cs | 62 +++++++++++++++++--
Lite/Analysis/AnalysisService.cs | 14 +++--
Lite/Analysis/SecondaryReplicaScope.cs | 6 +-
Lite/Mcp/McpAnalysisTools.cs | 17 ++---
.../AnalysisContext.cs | 9 ++-
.../FactReplicaScope.cs | 20 +++---
PerformanceMonitor.Common/AgReplicaScope.cs | 23 +++++--
15 files changed, 223 insertions(+), 75 deletions(-)
diff --git a/Darling/Darling.Tests/FinOpsRecommendationsSecondaryReplicaLiveTests.cs b/Darling/Darling.Tests/FinOpsRecommendationsSecondaryReplicaLiveTests.cs
index dad3ecd94e..19bd10a64a 100644
--- a/Darling/Darling.Tests/FinOpsRecommendationsSecondaryReplicaLiveTests.cs
+++ b/Darling/Darling.Tests/FinOpsRecommendationsSecondaryReplicaLiveTests.cs
@@ -83,9 +83,9 @@ public async Task ASecondaryCopy_IsDroppedFromIdleDevTestTdeAndCompression_ButKe
Assert.NotNull(Find(baseline, "uncompressed object"));
Assert.Contains("OrdersA", Find(baseline, "low IO latency")!.Detail, StringComparison.Ordinal);
- /* ArchiveA (idle), app_dev_a (dev/test) and SalesA (TDE and the 2 GB index) are secondary copies; the case of
- a name is the engine's, not the store's, so one is written in the wrong case on purpose. */
- var secondary = new HashSet(StringComparer.OrdinalIgnoreCase) { "archivea", "APP_DEV_A", "SalesA" };
+ /* ArchiveA (idle), app_dev_a (dev/test) and SalesA (TDE and the 2 GB index) are secondary copies. Names are matched
+ exactly (#5558 round 2): the set and the stored names both come from sys.databases.name, so the casing is shared. */
+ var secondary = new HashSet(StringComparer.Ordinal) { "ArchiveA", "app_dev_a", "SalesA" };
var rows = await DarlingFinOpsRecommendationsReader.GetRecommendationsAsync(
dataSource, ServerId, 1000m, TimeoutSeconds, secondaryDatabases: secondary, cancellationToken: ct);
diff --git a/Darling/Darling.Tests/LiteAnalysisServiceCancellationSourceTests.cs b/Darling/Darling.Tests/LiteAnalysisServiceCancellationSourceTests.cs
index 5fb5d43b6c..7d2d9a4532 100644
--- a/Darling/Darling.Tests/LiteAnalysisServiceCancellationSourceTests.cs
+++ b/Darling/Darling.Tests/LiteAnalysisServiceCancellationSourceTests.cs
@@ -39,7 +39,7 @@ public sealed class LiteAnalysisServiceCancellationSourceTests
private const string CollectConfigAuditFactsAsyncSignature =
"public async Task> CollectConfigAuditFactsAsync(";
private const string ComparePeriodsAsyncSignature =
- "public async Task<(List BaselineFacts, List ComparisonFacts, WindowCoverage? BaselineCoverage, WindowCoverage? ComparisonCoverage, IReadOnlyDictionary Dispersion)> ComparePeriodsAsync(";
+ "public async Task<(List BaselineFacts, List ComparisonFacts, WindowCoverage? BaselineCoverage, WindowCoverage? ComparisonCoverage, IReadOnlyDictionary Dispersion, IReadOnlySet? BaselineSecondaries, IReadOnlySet? ComparisonSecondaries)> ComparePeriodsAsync(";
private const string LookUpDispersionAsyncSignature =
"private async Task> LookUpDispersionAsync(";
// The next declaration after LookUpDispersionAsync in file order (#4203's four end here); anchors the
diff --git a/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs b/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs
index 2614bf6de7..6c5a060d2c 100644
--- a/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs
+++ b/Darling/Darling.Tests/SecondaryReplicaScopeLiveTests.cs
@@ -240,6 +240,33 @@ await WithStoreAsync(async (c, postgres, ct) =>
});
}
+ ///
+ /// #5558 round 2 (L1): compare_analysis and get_analysis_facts build secondary_replica_note from the set their own context
+ /// filtered with, not from a second read of the role. The baseline window ends before the only snapshot (nothing known,
+ /// nothing skipped); the comparison window ends after it (SecDb skipped). Each returned set is its own.
+ ///
+ [Fact]
+ public async Task CompareAndFactsReads_CarryTheSetTheirContextFilteredWith_SoTheNoteCannotDisagree()
+ {
+ await WithStoreAsync(async (c, postgres, ct) =>
+ {
+ var end = Now();
+ await SeedAgAsync(c, ct, end.AddMinutes(-1));
+ var service = new DarlingAnalysisService(postgres);
+
+ var (_, _, _, _, _, baselineSecondaries, comparisonSecondaries) = await service.ComparePeriodsAsync(
+ ServerId, ServerName, DarlingMcpTestData.Naive(end.AddHours(-30)), DarlingMcpTestData.Naive(end.AddHours(-26)),
+ DarlingMcpTestData.Naive(end.AddHours(-4)), DarlingMcpTestData.Naive(end), ct);
+ Assert.Empty(baselineSecondaries!);
+ Assert.Null(AgReplicaScope.SkippedNote(baselineSecondaries));
+ Assert.Equal(["SecDb"], comparisonSecondaries!.ToArray());
+ Assert.Equal(AgReplicaScope.SkippedNote(1), AgReplicaScope.SkippedNote(comparisonSecondaries));
+
+ var (_, _, caveats) = await service.CollectAndScoreFactsAsync(ServerId, ServerName, 4, DarlingMcpTestData.Naive(end), ct);
+ Assert.Equal(["SecDb"], caveats.SecondaryReplicaDatabases!.ToArray());
+ });
+ }
+
private static Task SeedRegressionAsync(NpgsqlConnection c, CancellationToken ct, DateTime end, string db, long planId, string hash,
long avgCpu, DateTime firstExec, DateTime lastExec, long queryId = 101) =>
ExecAsync(c, ct, @"
diff --git a/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs b/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs
index 8ab587c030..99bc1a4b94 100644
--- a/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs
+++ b/Darling/Darling.Tests/SecondaryReplicaScopeTests.cs
@@ -32,7 +32,7 @@ public sealed class PgSecondaryReplicaScopeTests
{
private static AnalysisContext ContextWith(params string[] secondaries) => new()
{
- SecondaryReplicaDatabases = new HashSet(secondaries, StringComparer.OrdinalIgnoreCase),
+ SecondaryReplicaDatabases = new HashSet(secondaries, StringComparer.Ordinal),
};
[Fact]
@@ -49,17 +49,53 @@ public void Apply_WithNothingToSkip_JustRemovesTheMarker_AndAddsNoParameter()
}
[Fact]
- public void Apply_WithASecondary_BindsTheLowerCasedNamesAsOneArray_NumberedAfterTheExistingParameters()
+ public void Apply_WithASecondary_BindsTheNamesAsTheyAreAsOneArray_NumberedAfterTheExistingParameters()
{
using var cmd = new NpgsqlCommand("SELECT 1 FROM t WHERE a = $1 /*SEC*/ ORDER BY 1");
cmd.Parameters.AddWithValue(1);
PgSecondaryReplicaScope.Apply(cmd, ContextWith("SecDb", "OtherDb"), "DB_CONFIG", "database_name");
- Assert.Contains("AND (database_name IS NULL OR lower(database_name) <> ALL($2::text[]))", cmd.CommandText, StringComparison.Ordinal);
+ Assert.Contains("AND (database_name IS NULL OR database_name <> ALL($2::text[]))", cmd.CommandText, StringComparison.Ordinal);
Assert.Equal(2, cmd.Parameters.Count);
var names = Assert.IsType(cmd.Parameters[1].Value);
- Assert.Equal(["otherdb", "secdb"], names.OrderBy(n => n, StringComparer.Ordinal).ToArray());
+ Assert.Equal(["OtherDb", "SecDb"], names.OrderBy(n => n, StringComparer.Ordinal).ToArray());
+ }
+
+ [Fact]
+ public void Apply_MatchesTheNameExactly_ASecondarySalesDbDoesNotHideASeparateSalesdb()
+ {
+ /* #5558 round 2: on a case-sensitive server collation, SalesDb (a secondary copy) and salesdb (a standalone
+ database) are two databases. The set carries only the secondary's exact name and the predicate compares
+ it as is, so the standalone one keeps its rows. */
+ using var cmd = new NpgsqlCommand("SELECT 1 FROM t WHERE a = $1 /*SEC*/");
+ cmd.Parameters.AddWithValue(1);
+
+ PgSecondaryReplicaScope.Apply(cmd, ContextWith("SalesDb"), "DB_CONFIG", "database_name");
+
+ Assert.Equal(["SalesDb"], Assert.IsType(cmd.Parameters[1].Value));
+ Assert.DoesNotContain("lower(", cmd.CommandText, StringComparison.Ordinal);
+ }
+
+ ///
+ /// #5558 round 2 (L4): the regressed-queries drill-down has two SQL variants, the raw one and the interval-table one, and
+ /// the live drill-down test runs whichever the seeded store picks. Each must carry the marker exactly once and before its
+ /// LIMIT 5; a variant that lost it would leave Apply binding a parameter nothing references and the filter
+ /// would silently not run there.
+ ///
+ [Theory]
+ [InlineData("raw")]
+ [InlineData("table")]
+ public void EachRegressedQueriesVariant_CarriesTheMarkerExactlyOnce_BeforeItsLimit(string variant)
+ {
+ var sql = variant == "raw" ? PgDrillDownCollector.RegressedQueriesSql : PgDrillDownCollector.RegressedQueriesTableSql;
+
+ var first = sql.IndexOf(PgSecondaryReplicaScope.Marker, StringComparison.Ordinal);
+ Assert.True(first >= 0, variant + " variant lost the secondary-replica marker");
+ Assert.Equal(-1, sql.IndexOf(PgSecondaryReplicaScope.Marker, first + PgSecondaryReplicaScope.Marker.Length, StringComparison.Ordinal));
+
+ var limit = sql.IndexOf("LIMIT 5", first, StringComparison.Ordinal);
+ Assert.True(limit > first, variant + " variant: the marker must come before its LIMIT 5, so the filter runs before the cut");
}
[Fact]
@@ -67,7 +103,7 @@ public void Apply_UsesTheKeywordItIsGiven_ForAClauseThatOpensTheWhere()
{
using var cmd = new NpgsqlCommand("SELECT 1 FROM t /*SEC*/ GROUP BY 1");
PgSecondaryReplicaScope.Apply(cmd, ContextWith("SecDb"), "PLAN_REGRESSION", "database_name", "WHERE");
- Assert.Contains(" WHERE (database_name IS NULL OR lower(database_name) <> ALL($1::text[]))", cmd.CommandText, StringComparison.Ordinal);
+ Assert.Contains(" WHERE (database_name IS NULL OR database_name <> ALL($1::text[]))", cmd.CommandText, StringComparison.Ordinal);
}
[Fact]
@@ -79,7 +115,7 @@ public void Apply_LeavesANodeLocalKeyUnfiltered_EvenWithASecondarySet()
PgSecondaryReplicaScope.Apply(cmd, ContextWith("SecDb"), "MISSING_INDEX", "database_name");
Assert.DoesNotContain("/*SEC*/", cmd.CommandText, StringComparison.Ordinal);
- Assert.DoesNotContain("lower(", cmd.CommandText, StringComparison.Ordinal);
+ Assert.DoesNotContain("<> ALL(", cmd.CommandText, StringComparison.Ordinal);
Assert.Single(cmd.Parameters);
}
@@ -91,7 +127,7 @@ public void Apply_WithNoSetOnTheContext_SkipsNothing()
PgSecondaryReplicaScope.Apply(cmd, new AnalysisContext(), "DB_CONFIG", "database_name");
- Assert.DoesNotContain("lower(", cmd.CommandText, StringComparison.Ordinal);
+ Assert.DoesNotContain("<> ALL(", cmd.CommandText, StringComparison.Ordinal);
Assert.Single(cmd.Parameters);
}
@@ -119,7 +155,7 @@ public async Task EnsureAsync_FillsTheSetOnce_AndLeavesAnAlreadyResolvedSetAlone
Assert.NotNull(context.SecondaryReplicaDatabases);
Assert.Empty(context.SecondaryReplicaDatabases!);
- var resolved = new HashSet(StringComparer.OrdinalIgnoreCase) { "SecDb" };
+ var resolved = new HashSet(StringComparer.Ordinal) { "SecDb" };
var already = new AnalysisContext { ServerId = 1, SecondaryReplicaDatabases = resolved };
await PgSecondaryReplicaScope.EnsureAsync(unreachable, already, logger: null);
Assert.Same(resolved, already.SecondaryReplicaDatabases);
@@ -183,10 +219,14 @@ same name cannot borrow the first one's call. */
foreach (Match m in construction.Matches(text))
{
sites++;
- var close = text.IndexOf("};", m.Index, StringComparison.Ordinal);
- var initializer = close < 0 ? text[m.Index..] : text[m.Index..close];
var end = memberEnd.Match(text, m.Index);
- var member = end.Success ? text[m.Index..end.Index] : text[m.Index..];
+ var limit = end.Success ? end.Index : text.Length;
+ var member = text[m.Index..limit];
+ /* The initializer's closing brace is searched only up to the member's end: an inline construction
+ such as Foo(new AnalysisContext { ... }); closes with "})", and an unbounded search would run on into
+ a LATER construction's initializer and borrow its SecondaryReplicaDatabases setting (#5558 round 2). */
+ var close = text.IndexOf("};", m.Index, limit - m.Index, StringComparison.Ordinal);
+ var initializer = close < 0 ? member : text[m.Index..close];
var name = m.Groups["name"].Value;
var inInitializer = initializer.Contains("SecondaryReplicaDatabases", StringComparison.Ordinal);
var ensured = name.Length > 0 && Regex.IsMatch(member, @"SecondaryReplicaScope\.EnsureAsync\([^)]*\b" + Regex.Escape(name) + @"\b");
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
index 5b6a5ec717..336678f13a 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/DarlingAnalysisService.cs
@@ -910,8 +910,13 @@ context.CancellationToken is the caller's own token. */
/// that fails must not cost the caller the comparison it was only meant to refine, so it degrades
/// to an empty map and every key takes the absolute rule — the never-blind fallback the anomaly
/// gate follows.
+ ///
+ /// #5558: the last two elements are the secondary-replica sets the two windows' contexts filtered with (null
+ /// when collection threw), each as of its own window end. compare_analysis builds its per-window
+ /// secondary_replica_note from them rather than reading the role a second time, so a note cannot name a skip
+ /// the facts did not make.
///
- public async Task<(List BaselineFacts, List ComparisonFacts, WindowCoverage? BaselineCoverage, WindowCoverage? ComparisonCoverage, IReadOnlyDictionary Dispersion)> ComparePeriodsAsync(
+ public async Task<(List BaselineFacts, List ComparisonFacts, WindowCoverage? BaselineCoverage, WindowCoverage? ComparisonCoverage, IReadOnlyDictionary Dispersion, IReadOnlySet? BaselineSecondaries, IReadOnlySet? ComparisonSecondaries)> ComparePeriodsAsync(
int serverId, string serverName,
DateTime baselineStart, DateTime baselineEnd,
DateTime comparisonStart, DateTime comparisonEnd,
@@ -952,7 +957,8 @@ context.CancellationToken is the caller's own token. */
var dispersion = await LookUpDispersionAsync(engine, serverId, serverName, baselineFacts, comparisonFacts, comparisonStart);
- return (baselineFacts, comparisonFacts, baselineContext.Coverage, comparisonContext.Coverage, dispersion);
+ return (baselineFacts, comparisonFacts, baselineContext.Coverage, comparisonContext.Coverage, dispersion,
+ baselineContext.SecondaryReplicaDatabases, comparisonContext.SecondaryReplicaDatabases);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -962,7 +968,7 @@ not be swallowed into an empty result and logged as a fault — this read has no
caller's own token, the same reasoning CollectConfigAuditFactsAsync's catch already states (#4203). */
_logger?.LogError("[DarlingAnalysisService] Period comparison failed for {Server}: {Message}",
serverName, ex.Message);
- return ([], [], null, null, new Dictionary());
+ return ([], [], null, null, new Dictionary(), null, null);
}
}
@@ -1336,7 +1342,7 @@ private async Task AttributeConfigChangesAsync(AnalysisEngineSet engine, Analysi
var windows = ConfigChangeAttribution.WindowsFor(anchorTime, context.TimeRangeEnd);
context.CancellationToken.ThrowIfCancellationRequested();
- var (before, after, beforeCoverage, afterCoverage, dispersion) = await ComparePeriodsAsync(
+ var (before, after, beforeCoverage, afterCoverage, dispersion, _, _) = await ComparePeriodsAsync(
context.ServerId, context.ServerName,
windows.BeforeStart, windows.BeforeEnd,
windows.AfterStart, windows.AfterEnd);
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgSecondaryReplicaScope.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgSecondaryReplicaScope.cs
index 0bb6df0353..d982f930bf 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgSecondaryReplicaScope.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgSecondaryReplicaScope.cs
@@ -30,7 +30,7 @@ public static class PgSecondaryReplicaScope
public const string Marker = "/*SEC*/";
/// The set a pass that reads only node-local facts carries: nothing is skipped, and no AG read is paid for.
- public static IReadOnlySet NoneSkipped { get; } = new HashSet(StringComparer.OrdinalIgnoreCase);
+ public static IReadOnlySet NoneSkipped { get; } = new HashSet(StringComparer.Ordinal);
/// Fills the set once per context. Never throws except an abandonment: a read fault logs and leaves the pass unfiltered.
public static async Task EnsureAsync(NpgsqlDataSource postgres, AnalysisContext context, Microsoft.Extensions.Logging.ILogger? logger)
@@ -52,7 +52,7 @@ public static async Task> ReadAsync(
var replicaTime = await NewestAsync(connection, "ag_replica_states", serverId, asOf, cancellationToken);
var databaseTime = await NewestAsync(connection, "ag_database_replica_states", serverId, asOf, cancellationToken);
if (!AgReplicaScope.IsFresh(replicaTime, asOfUtc) || !AgReplicaScope.IsFresh(databaseTime, asOfUtc))
- return new HashSet(StringComparer.OrdinalIgnoreCase);
+ return new HashSet(StringComparer.Ordinal);
var replicas = new List();
using (var cmd = new NpgsqlCommand(
@@ -95,7 +95,7 @@ public static async Task> ReadAsync(
catch (Exception ex) when (!AnalysisShutdown.IsExpectedAbandon(ex, cancellationToken))
{
logger?.LogWarning(ex, "Availability Group role lookup failed for server {ServerId}; no database is skipped this pass", serverId);
- return new HashSet(StringComparer.OrdinalIgnoreCase);
+ return new HashSet(StringComparer.Ordinal);
}
}
@@ -128,7 +128,7 @@ public static async Task> ReadAsync(
///
/// Replaces in the command's text with the predicate that drops the skipped databases of
- /// (), binding the lower-cased names as one text[]
+ /// (), binding the names (exact casing) as one text[]
/// parameter numbered after the parameters already added. is AND for a clause that
/// extends a WHERE and WHERE for one that opens it. With nothing to skip the marker just disappears: the SQL is
/// the same as before and no parameter is added. Call it after every other parameter is bound.
@@ -145,6 +145,6 @@ public static void Apply(NpgsqlCommand cmd, AnalysisContext context, string fact
var index = cmd.Parameters.Count + 1;
cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Array | NpgsqlTypes.NpgsqlDbType.Text, Value = names });
cmd.CommandText = cmd.CommandText.Replace(
- Marker, $" {keyword} ({column} IS NULL OR lower({column}) <> ALL(${index}::text[]))", StringComparison.Ordinal);
+ Marker, $" {keyword} ({column} IS NULL OR {column} <> ALL(${index}::text[]))", StringComparison.Ordinal);
}
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs
index b78bfc9336..9524c0cdf0 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs
@@ -341,10 +341,10 @@ public static async Task GetAnalysisFacts(
resolved.ServerId, resolved.ServerName, hours_back, asOfUtc: anchor, cancellationToken: cancellationToken);
/* #5558: databases left to the primary because this node holds only a secondary copy of them in an
- availability group, as of the window end; the facts below were trimmed of them. Read like
- compare_analysis and get_analysis_findings read it, not through the service's last-pass state, so two
- concurrent reads cannot swap notes. Null when none, and then the field is simply null. */
- var secondaryReplicaNote = await PgSecondaryReplicaScope.NoteAsync(postgres, resolved.ServerId, null, cancellationToken, anchor);
+ availability group, as of the window end; the facts below were trimmed of them. Built from the set the
+ read's own context filtered with (carried on the returned collection state), not from a second read, so
+ the note cannot disagree with what was skipped. Null when none, and then the field is simply null. */
+ var secondaryReplicaNote = AgReplicaScope.SkippedNote(collection.SecondaryReplicaDatabases);
/* #3691: null on a clean read, and then every envelope below is byte-for-byte what it was; when
a family failed, the sentence is appended to the message and collection_caveats to the payload. */
@@ -481,16 +481,17 @@ silently change what the two windows are relative to each other. */
var baselineEnd = windowEnd.AddHours(-baseline_hours_back + hours_back);
var baselineStart = windowEnd.AddHours(-baseline_hours_back);
- var (baselineFacts, comparisonFacts, baselineCoverage, comparisonCoverage, dispersion) = await analysisService.ComparePeriodsAsync(
+ var (baselineFacts, comparisonFacts, baselineCoverage, comparisonCoverage, dispersion, baselineSecondaries, comparisonSecondaries) = await analysisService.ComparePeriodsAsync(
resolved.ServerId, resolved.ServerName,
baselineStart, baselineEnd,
comparisonStart, comparisonEnd,
cancellationToken);
/* #5558: per window, because each window uses the role at its own end and the two can straddle a
- failover. Same read the windows' contexts made, so the note names what the comparison skipped. */
- var baselineSecondaryNote = await PgSecondaryReplicaScope.NoteAsync(postgres, resolved.ServerId, null, cancellationToken, baselineEnd);
- var comparisonSecondaryNote = await PgSecondaryReplicaScope.NoteAsync(postgres, resolved.ServerId, null, cancellationToken, comparisonEnd);
+ failover. Built from the sets the windows' contexts filtered with (returned beside the coverages), not
+ from a second read, so the note names exactly what the comparison skipped. */
+ var baselineSecondaryNote = AgReplicaScope.SkippedNote(baselineSecondaries);
+ var comparisonSecondaryNote = AgReplicaScope.SkippedNote(comparisonSecondaries);
/* The COLLECTION_GAP context fact (#3538 A2) is an observation of the COLLECTOR, not of the
server, and it is reported through the coverage blocks and caveat below. Left in the
diff --git a/Lite.Tests/FinOpsSecondaryReplicaTests.cs b/Lite.Tests/FinOpsSecondaryReplicaTests.cs
index c9bd429fb1..3761ac15d3 100644
--- a/Lite.Tests/FinOpsSecondaryReplicaTests.cs
+++ b/Lite.Tests/FinOpsSecondaryReplicaTests.cs
@@ -145,10 +145,13 @@ public async Task ASecondaryCopy_StaysInTheLowIoLatencyList()
[Fact]
public void TheFilterHelper_DropsOnlyTheSecondaries_ThatTheDevTestTdeAndCompressionRulesShare()
{
- var secondary = new HashSet(StringComparer.OrdinalIgnoreCase) { "SalesDev" };
+ var secondary = new HashSet(StringComparer.Ordinal) { "SalesDev" };
- Assert.Equal(["OrdersTest", "qa_one"], AgReplicaScope.WithoutSecondaries(["salesdev", "OrdersTest", "qa_one"], secondary));
- Assert.True(AgReplicaScope.IsSkipped(secondary, "SALESDEV"));
+ /* Exact names (#5558 round 2): the set and the lists come from the same catalog, so a name that differs only by
+ case is a different database on a case-sensitive server and is kept. */
+ Assert.Equal(["salesdev", "OrdersTest", "qa_one"], AgReplicaScope.WithoutSecondaries(["salesdev", "SalesDev", "OrdersTest", "qa_one"], secondary));
+ Assert.True(AgReplicaScope.IsSkipped(secondary, "SalesDev"));
+ Assert.False(AgReplicaScope.IsSkipped(secondary, "SALESDEV"));
Assert.False(AgReplicaScope.IsSkipped(secondary, "OrdersTest"));
/* Fail open: nothing, null and blank skip nothing. */
Assert.Equal(["a", "b"], AgReplicaScope.WithoutSecondaries(["a", "b"], null));
diff --git a/Lite.Tests/SecondaryReplicaScopeTests.cs b/Lite.Tests/SecondaryReplicaScopeTests.cs
index c5f008e616..ca6f9d62db 100644
--- a/Lite.Tests/SecondaryReplicaScopeTests.cs
+++ b/Lite.Tests/SecondaryReplicaScopeTests.cs
@@ -91,11 +91,17 @@ public void NoRows_OrAStandaloneDatabase_SkipsNothing()
}
[Fact]
- public void Names_AreMatchedWithoutRegardToCase_AndRoleCaseDoesNotMatter()
+ public void RoleCaseDoesNotMatter_ButDatabaseNamesAreKeptExactly()
{
- var set = AgReplicaScope.SecondaryDatabases([Replica("ag1", "secondary")], [Db("AG1", "Sales")]);
- Assert.Contains("SALES", set);
- Assert.Contains("sales", set);
+ /* #5558 round 2: the snapshot's database_name is sys.databases.name, the same catalog the replicated facts'
+ names come from, so the casing is identical on both sides and the match is ordinal. On a case-sensitive
+ server collation a secondary SalesDb and a separate salesdb are two databases. */
+ var set = AgReplicaScope.SecondaryDatabases([Replica("ag1", "secondary")], [Db("AG1", "SalesDb")]);
+ Assert.Contains("SalesDb", set);
+ Assert.DoesNotContain("salesdb", set);
+ Assert.True(AgReplicaScope.IsSkipped(set, "SalesDb"));
+ Assert.False(AgReplicaScope.IsSkipped(set, "salesdb"));
+ Assert.Equal(["salesdb"], AgReplicaScope.WithoutSecondaries(["SalesDb", "salesdb"], set));
}
[Fact]
@@ -165,8 +171,8 @@ public void AReplicatedKey_YieldsNames_OnlyWhenTheContextHasASecondarySet()
var empty = new AnalysisContext();
Assert.Empty(FactReplicaScope.SecondariesFor(empty, "DB_CONFIG"));
- var context = new AnalysisContext { SecondaryReplicaDatabases = new HashSet(StringComparer.OrdinalIgnoreCase) { "SecDb" } };
- Assert.Equal(["secdb"], FactReplicaScope.SecondariesFor(context, "DB_CONFIG"));
+ var context = new AnalysisContext { SecondaryReplicaDatabases = new HashSet(StringComparer.Ordinal) { "SecDb" } };
+ Assert.Equal(["SecDb"], FactReplicaScope.SecondariesFor(context, "DB_CONFIG"));
Assert.Empty(FactReplicaScope.SecondariesFor(context, "MISSING_INDEX"));
}
@@ -302,6 +308,50 @@ public async Task OnASecondary_DbConfigAndAutogrowthDropTheSecondaryDatabase_And
Assert.Equal(["PrimDb", "StandDb"], files.Select(f => f.GetProperty("database").GetString()!).OrderBy(n => n).ToArray());
}
+ [Fact]
+ public async Task ASecondarySecDb_DoesNotHideASeparateDatabaseNamedSecdb()
+ {
+ /* #5558 round 2: on a case-sensitive server collation these are two databases; the filter compares the
+ exact name, so the lower-case twin keeps its DB_CONFIG row. */
+ var end = Now();
+ await SeedAsync(async c =>
+ {
+ await SeedDatabasesAsync(c, end);
+ await LatestValueSeed.InsertDatabaseConfigAsync(c, ServerId, end.AddDays(-1), "secdb", autoShrink: true, rcsiOn: false);
+ await SeedAgAsync(c, end.AddMinutes(-1));
+ });
+
+ var (facts, context) = await FactsAsync(end);
+
+ Assert.Equal(["SecDb"], context.SecondaryReplicaDatabases!.ToArray());
+ var config = facts["DB_CONFIG"];
+ Assert.Equal(3, config.Metadata["database_count"]);
+ Assert.Equal(3, config.Metadata["auto_shrink_on_count"]);
+ }
+
+ [Fact]
+ public async Task CompareAndFactsReads_CarryTheSetTheirContextFilteredWith_SoTheNoteCannotDisagree()
+ {
+ /* #5558 round 2: compare_analysis and get_analysis_facts build secondary_replica_note from what the read
+ returns, not from a second read of the role. The baseline window ends BEFORE the only snapshot (nothing known,
+ nothing skipped) and the comparison window ends after it (SecDb skipped): each window's returned set is its own. */
+ var end = Now();
+ await SeedAsync(async c => { await SeedDatabasesAsync(c, end); await SeedAgAsync(c, end.AddMinutes(-1)); });
+ var service = new AnalysisService(_duckDb);
+
+ var (_, _, _, _, _, baselineSecondaries, comparisonSecondaries) = await service.ComparePeriodsAsync(
+ ServerId, "ag-scope",
+ end.AddHours(-30), end.AddHours(-26), end.AddHours(-4), end,
+ TestContext.Current.CancellationToken);
+ Assert.Empty(baselineSecondaries!);
+ Assert.Null(AgReplicaScope.SkippedNote(baselineSecondaries));
+ Assert.Equal(["SecDb"], comparisonSecondaries!.ToArray());
+ Assert.Equal(AgReplicaScope.SkippedNote(1), AgReplicaScope.SkippedNote(comparisonSecondaries));
+
+ var (_, _, caveats) = await service.CollectAndScoreFactsAsync(ServerId, "ag-scope", 4, end, TestContext.Current.CancellationToken);
+ Assert.Equal(["SecDb"], caveats.SecondaryReplicaDatabases!.ToArray());
+ }
+
[Fact]
public async Task TheDbConfigDrillDown_ListsTheSameDatabasesTheFactCounted()
{
diff --git a/Lite/Analysis/AnalysisService.cs b/Lite/Analysis/AnalysisService.cs
index 11e1378fe7..5f322a09d6 100644
--- a/Lite/Analysis/AnalysisService.cs
+++ b/Lite/Analysis/AnalysisService.cs
@@ -667,8 +667,13 @@ not be swallowed into an empty result and logged as a fault. */
/// that fails must not cost the caller the comparison it was only meant to refine, so it degrades
/// to an empty map and every key takes the absolute rule — the never-blind fallback the anomaly
/// gate follows.
+ ///
+ /// #5558: the last two elements are the secondary-replica sets the two windows' contexts filtered with (null
+ /// when collection threw), each as of its own window end. compare_analysis builds its per-window
+ /// secondary_replica_note from them rather than reading the role a second time, so a note cannot name a skip
+ /// the facts did not make.
///
- public async Task<(List BaselineFacts, List ComparisonFacts, WindowCoverage? BaselineCoverage, WindowCoverage? ComparisonCoverage, IReadOnlyDictionary Dispersion)> ComparePeriodsAsync(
+ public async Task<(List BaselineFacts, List ComparisonFacts, WindowCoverage? BaselineCoverage, WindowCoverage? ComparisonCoverage, IReadOnlyDictionary Dispersion, IReadOnlySet? BaselineSecondaries, IReadOnlySet? ComparisonSecondaries)> ComparePeriodsAsync(
int serverId, string serverName,
DateTime baselineStart, DateTime baselineEnd,
DateTime comparisonStart, DateTime comparisonEnd,
@@ -708,14 +713,15 @@ uses the role at its own end. */
var dispersion = await LookUpDispersionAsync(serverId, serverName, baselineFacts, comparisonFacts, comparisonStart, cancellationToken);
- return (baselineFacts, comparisonFacts, baselineContext.Coverage, comparisonContext.Coverage, dispersion);
+ return (baselineFacts, comparisonFacts, baselineContext.Coverage, comparisonContext.Coverage, dispersion,
+ baselineContext.SecondaryReplicaDatabases, comparisonContext.SecondaryReplicaDatabases);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
/* #4203: cancellation (an abandoned MCP read) must reach the caller as OperationCanceledException,
not be swallowed into an empty result and logged as a fault. */
AppLogger.Error("AnalysisService", $"Period comparison failed for {serverName}: {ex.Message}");
- return ([], [], null, null, new Dictionary());
+ return ([], [], null, null, new Dictionary(), null, null);
}
}
@@ -1007,7 +1013,7 @@ private async Task AttributeConfigChangesAsync(AnalysisContext context, ListThe set a pass that reads only node-local facts carries: nothing is skipped, and no AG read is paid for.
- internal static IReadOnlySet NoneSkipped { get; } = new HashSet(StringComparer.OrdinalIgnoreCase);
+ internal static IReadOnlySet NoneSkipped { get; } = new HashSet(StringComparer.Ordinal);
/// Fills the set once per context. Never throws except an abandonment (#2443): a read fault logs and
/// leaves the pass unfiltered.
@@ -50,7 +50,7 @@ internal static async Task> ReadAsync(
var replicaTime = await NewestAsync(connection, "v_ag_replica_states", serverId, asOfUtc, cancellationToken);
var databaseTime = await NewestAsync(connection, "v_ag_database_replica_states", serverId, asOfUtc, cancellationToken);
if (!AgReplicaScope.IsFresh(replicaTime, asOfUtc) || !AgReplicaScope.IsFresh(databaseTime, asOfUtc))
- return new HashSet(StringComparer.OrdinalIgnoreCase);
+ return new HashSet(StringComparer.Ordinal);
var replicas = new List();
using (var cmd = connection.CreateCommand())
@@ -100,7 +100,7 @@ FROM v_ag_database_replica_states
{
AppLogger.Warn("SecondaryReplicaScope",
$"Availability Group role lookup failed for server {serverId}; no database is skipped this pass: {ex.Message}");
- return new HashSet(StringComparer.OrdinalIgnoreCase);
+ return new HashSet(StringComparer.Ordinal);
}
}
diff --git a/Lite/Mcp/McpAnalysisTools.cs b/Lite/Mcp/McpAnalysisTools.cs
index d710ac1063..b5b42ca264 100644
--- a/Lite/Mcp/McpAnalysisTools.cs
+++ b/Lite/Mcp/McpAnalysisTools.cs
@@ -307,10 +307,10 @@ public static async Task GetAnalysisFacts(
resolved.ServerId, resolved.ServerName, hours_back, asOfUtc: anchor, cancellationToken);
/* #5558: databases left to the primary because this node holds only a secondary copy of them in an
- availability group, as of the window end; the facts below were trimmed of them. Read like
- compare_analysis and get_analysis_findings read it, not through the service's last-pass state, so two
- concurrent reads cannot swap notes. Null when none, and then the field is simply null. */
- var secondaryReplicaNote = await analysisService.GetSecondaryReplicaNoteAsync(resolved.ServerId, anchor, cancellationToken);
+ availability group, as of the window end; the facts below were trimmed of them. Built from the set the
+ read's own context filtered with (carried on the returned collection state), not from a second read, so
+ the note cannot disagree with what was skipped. Null when none, and then the field is simply null. */
+ var secondaryReplicaNote = AgReplicaScope.SkippedNote(collection.SecondaryReplicaDatabases);
/* #3691: null on a clean read, and then every envelope below is byte-for-byte what it was; when
a family failed, the sentence is appended to the message and collection_caveats to the payload. */
@@ -447,16 +447,17 @@ silently change what the two windows are relative to each other. */
var baselineEnd = windowEnd.AddHours(-baseline_hours_back + hours_back);
var baselineStart = windowEnd.AddHours(-baseline_hours_back);
- var (baselineFacts, comparisonFacts, baselineCoverage, comparisonCoverage, dispersion) = await analysisService.ComparePeriodsAsync(
+ var (baselineFacts, comparisonFacts, baselineCoverage, comparisonCoverage, dispersion, baselineSecondaries, comparisonSecondaries) = await analysisService.ComparePeriodsAsync(
resolved.ServerId, resolved.ServerName,
baselineStart, baselineEnd,
comparisonStart, comparisonEnd,
cancellationToken);
/* #5558: per window, because each window uses the role at its own end and the two can straddle a
- failover. Same read the windows' contexts made, so the note names what the comparison skipped. */
- var baselineSecondaryNote = await analysisService.GetSecondaryReplicaNoteAsync(resolved.ServerId, baselineEnd, cancellationToken);
- var comparisonSecondaryNote = await analysisService.GetSecondaryReplicaNoteAsync(resolved.ServerId, comparisonEnd, cancellationToken);
+ failover. Built from the sets the windows' contexts filtered with (returned beside the coverages), not
+ from a second read, so the note names exactly what the comparison skipped. */
+ var baselineSecondaryNote = AgReplicaScope.SkippedNote(baselineSecondaries);
+ var comparisonSecondaryNote = AgReplicaScope.SkippedNote(comparisonSecondaries);
/* The COLLECTION_GAP context fact (#3538 A2) is an observation of the COLLECTOR, not of the
server, and it is reported through the coverage blocks and caveat below. Left in the
diff --git a/PerformanceMonitor.Analysis/AnalysisContext.cs b/PerformanceMonitor.Analysis/AnalysisContext.cs
index 08fc128302..e394cc4354 100644
--- a/PerformanceMonitor.Analysis/AnalysisContext.cs
+++ b/PerformanceMonitor.Analysis/AnalysisContext.cs
@@ -386,18 +386,21 @@ public static string FamilyOf(string collectMethod)
///
/// What a facts read hands its caller about collection (#3691): the failures and the family total off the
-/// context the read ran on, as ONE value so the read's tuple grows by one element rather than two. Returned
+/// context the read ran on, as ONE value so the read's tuple grows by one element rather than two (#5558 adds the
+/// secondary-replica set the read filtered with, riding the same value for the same reason; the callers build the
+/// secondary_replica_note from it with AgReplicaScope.SkippedNote, which this project cannot reference). Returned
/// rather than parked on a service property for the reason CollectAndScoreFactsAsync's coverage is:
/// that path has no IsAnalyzing guard, two on-demand callers can overlap, and a shared property would
/// let one read the other's failures.
///
-public sealed record CollectionCaveatState(IReadOnlyList Failures, int FamiliesTotal)
+public sealed record CollectionCaveatState(
+ IReadOnlyList Failures, int FamiliesTotal, IReadOnlySet? SecondaryReplicaDatabases = null)
{
/// The state the context carries after its collector ran (or threw before recording anything).
public static CollectionCaveatState From(AnalysisContext context)
{
ArgumentNullException.ThrowIfNull(context);
- return new(context.CollectionFailures, context.CollectionFamilyCount);
+ return new(context.CollectionFailures, context.CollectionFamilyCount, context.SecondaryReplicaDatabases);
}
/// True when at least one family failed — the only case in which anything is emitted.
diff --git a/PerformanceMonitor.Analysis/FactReplicaScope.cs b/PerformanceMonitor.Analysis/FactReplicaScope.cs
index e7703c0d02..05337d3bd9 100644
--- a/PerformanceMonitor.Analysis/FactReplicaScope.cs
+++ b/PerformanceMonitor.Analysis/FactReplicaScope.cs
@@ -100,27 +100,27 @@ public static FactReplicaKind Of(string key)
return FactReplicaKind.NodeLocal;
}
- /// The lower-cased database names a read must skip on this pass: the context's
+ /// The database names (exact casing: see AgReplicaScope, "Name matching") a read must skip on this pass: the context's
/// secondary set when the key is , else empty. A read builds its
/// predicate from a non-empty answer and leaves its SQL byte-identical on an empty one.
public static string[] SecondariesFor(AnalysisContext context, string factKey)
{
if (context.SecondaryReplicaDatabases is not { Count: > 0 } set) return [];
if (Of(factKey) != FactReplicaKind.Replicated) return [];
- return LowerNames(set);
+ return Names(set);
}
- /// The set's names lower-cased, de-duplicated and ordered, for a SQL lower(database_name) compare.
- public static string[] LowerNames(IEnumerable names) =>
- names.Select(n => n.ToLowerInvariant()).Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
+ /// The set's names as they are, de-duplicated and ordered, for an exact SQL database_name compare.
+ public static string[] Names(IEnumerable names) =>
+ names.Distinct(StringComparer.Ordinal).OrderBy(n => n, StringComparer.Ordinal).ToArray();
/// A DuckDB predicate fragment (leading AND) that drops rows of the skipped databases, with positional
/// parameters starting at ; empty when there is nothing to skip. A NULL
- /// database passes. Add the values with in the same order.
- public static string PositionalFilter(string[] lowerNames, string column, int firstParameter)
+ /// database passes. Add the values with in the same order.
+ public static string PositionalFilter(string[] names, string column, int firstParameter)
{
- if (lowerNames.Length == 0) return string.Empty;
- var placeholders = string.Join(", ", lowerNames.Select((_, i) => "$" + (firstParameter + i)));
- return " AND (" + column + " IS NULL OR lower(" + column + ") NOT IN (" + placeholders + "))";
+ if (names.Length == 0) return string.Empty;
+ var placeholders = string.Join(", ", names.Select((_, i) => "$" + (firstParameter + i)));
+ return " AND (" + column + " IS NULL OR " + column + " NOT IN (" + placeholders + "))";
}
}
diff --git a/PerformanceMonitor.Common/AgReplicaScope.cs b/PerformanceMonitor.Common/AgReplicaScope.cs
index 08bf6eabce..c1a422517f 100644
--- a/PerformanceMonitor.Common/AgReplicaScope.cs
+++ b/PerformanceMonitor.Common/AgReplicaScope.cs
@@ -42,6 +42,17 @@ namespace PerformanceMonitor.Common;
/// its own (second-level) group and a SECONDARY under the distributed group. A database with any non-secondary local
/// role is kept, so the forwarder skips nothing and reports the replicated findings, as a primary does. That is
/// deliberate (fail open): do not "fix" it by treating the distributed group's secondary role as decisive.
+///
+/// Name matching (ordinal). A database name is matched exactly, with no case folding, here and in the
+/// SQL predicates built from the set (FactReplicaScope, PgSecondaryReplicaScope). Both sides come from
+/// the same catalog and so carry identical casing: the AG snapshot's database_name is sys.databases.name
+/// (AgDatabaseReplicaStatesCollector, d.name), and the replicated facts' names are
+/// sys.databases.name too (DatabaseConfigCollector d.name, QueryStoreCollector and
+/// IndexObjectStatsCollector d.name / DB_NAME(), FileIoStatsCollector DB_NAME()). On a
+/// case-sensitive server collation a secondary SalesDb and a separate salesdb are different databases,
+/// and folding case would hide the second one's findings. The limit: a database whose name was renamed by case only
+/// (ALTER DATABASE ... MODIFY NAME) between a stored row and the snapshot is not matched until the rows
+/// are re-collected, which only ever shows a finding (fail open).
///
public static class AgReplicaScope
{
@@ -93,8 +104,8 @@ public static IReadOnlySet SecondaryDatabases(
}
}
- var secondary = new HashSet(StringComparer.OrdinalIgnoreCase);
- var notSecondary = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var secondary = Empty();
+ var notSecondary = Empty();
foreach (var database in databases)
{
if (database.IsLocal != true || string.IsNullOrWhiteSpace(database.DatabaseName)
@@ -124,13 +135,13 @@ public static IReadOnlySet SecondaryDatabases(
public static string? SkippedNote(IReadOnlyCollection? secondaryDatabases) =>
secondaryDatabases is null ? null : SkippedNote(secondaryDatabases.Count);
- /// True when is in the skipped set (case-insensitive, like the engine's
- /// database names). A null or empty set, or a null/blank name, is false: fail open.
+ /// True when is in the skipped set (an exact, ordinal match: see
+ /// , "Name matching"). A null or empty set, or a null/blank name, is false: fail open.
public static bool IsSkipped(IReadOnlyCollection? secondaryDatabases, string? databaseName)
{
if (secondaryDatabases is not { Count: > 0 } || string.IsNullOrWhiteSpace(databaseName)) return false;
foreach (var name in secondaryDatabases)
- if (string.Equals(name, databaseName, StringComparison.OrdinalIgnoreCase)) return true;
+ if (string.Equals(name, databaseName, StringComparison.Ordinal)) return true;
return false;
}
@@ -140,5 +151,5 @@ public static bool IsSkipped(IReadOnlyCollection? secondaryDatabases, st
public static List WithoutSecondaries(IEnumerable databaseNames, IReadOnlyCollection? secondaryDatabases) =>
databaseNames.Where(name => !IsSkipped(secondaryDatabases, name)).ToList();
- private static HashSet Empty() => new(StringComparer.OrdinalIgnoreCase);
+ private static HashSet Empty() => new(StringComparer.Ordinal);
}