diff --git a/Darling/Darling.Tests/PgTargetBetweenWavesTests.cs b/Darling/Darling.Tests/PgTargetBetweenWavesTests.cs
new file mode 100644
index 0000000000..6bfecdd668
--- /dev/null
+++ b/Darling/Darling.Tests/PgTargetBetweenWavesTests.cs
@@ -0,0 +1,370 @@
+/*
+ * 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 ModelContextProtocol.Server;
+using PerformanceMonitor.Analysis;
+using PerformanceMonitor.Darling.Analysis;
+using PerformanceMonitor.Darling.Service.Mcp;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// The between-waves shared-vocabulary pass of #3542 — the items six content lanes reported that could not be
+/// made inside a file-disjoint lane: the bad-actor ALIAS and its story-build-time resolution, the two vacuum-side
+/// config arms that were inert because their source is pg_config and no arm gave them a base, the
+/// PostgreSQL 16+ reserved_connections carve-out in the connection ceiling, and the bad actor's first
+/// next-read. Each pin here is the property the item's decision rests on; the families' own tests keep their
+/// pins and the ones this pass deliberately moved say so inline.
+///
+public sealed class PgTargetBetweenWavesTests
+{
+ /* ───────────────────────── item 1: the bad-actor alias ───────────────────────── */
+
+ [Fact]
+ public void TheAlias_IsNeverABadActorKey_AndNoArmClaimsItByPrefix()
+ {
+ Assert.Equal("PG_BAD_ACTOR", PgTargetFactKeys.BadActorFamily);
+ /* The alias lacks the prefix's trailing underscore, so nothing routing on BadActorKeyPrefix can claim it —
+ the queries scorer, advice, tool recommendations and ResolveBadActor itself all skip it. */
+ Assert.False(PgTargetFactKeys.BadActorFamily.StartsWith(PgTargetFactKeys.BadActorKeyPrefix, StringComparison.Ordinal));
+ Assert.True(PgTargetRelationshipGraph.IsBadActorAlias(PgTargetFactKeys.BadActorFamily));
+ Assert.False(PgTargetRelationshipGraph.IsBadActorAlias(PgTargetFactKeys.BadActorKey(42)));
+ Assert.Null(PgTargetToolRecommendations.GetForKey(PgTargetFactKeys.BadActorFamily));
+ Assert.Null(PgTargetRelationshipGraph.ResolveBadActor(new Dictionary
+ {
+ [PgTargetFactKeys.BadActorFamily] = new() { Key = PgTargetFactKeys.BadActorFamily, Severity = 2.0 },
+ }));
+ }
+
+ [Fact]
+ public void ResolveBadActor_TakesTheHigherSeverity_TiesByOrdinalKey_AndIsNullWithNone()
+ {
+ var low = BadActor(11, severity: 0.55);
+ var high = BadActor(7, severity: 1.0);
+ Assert.Equal(high.Key, PgTargetRelationshipGraph.ResolveBadActor(Lookup(low, high)));
+ Assert.Equal(high.Key, PgTargetRelationshipGraph.ResolveBadActor(Lookup(high, low)));
+
+ /* Two at the same severity resolve the same way regardless of dictionary order. */
+ var tieA = BadActor(200, severity: 0.7);
+ var tieB = BadActor(100, severity: 0.7);
+ Assert.Equal(tieB.Key, PgTargetRelationshipGraph.ResolveBadActor(Lookup(tieA, tieB)));
+ Assert.Equal(tieB.Key, PgTargetRelationshipGraph.ResolveBadActor(Lookup(tieB, tieA)));
+
+ Assert.Null(PgTargetRelationshipGraph.ResolveBadActor(Lookup(Spill(0.6))));
+ Assert.Null(PgTargetRelationshipGraph.ResolveBadActor(new Dictionary()));
+ }
+
+ [Fact]
+ public void AnEdgeIntoTheAlias_ResolvesToTheHighestSeverityBadActor_ThroughTheRealTraversal()
+ {
+ var graph = GraphWithAliasEdge();
+ var spill = Spill(0.6);
+ /* Both bad actors are under the 0.5 story line so neither roots (and is consumed) before the spill walks —
+ the same rule every leaf on the SQL Server graph lives under; a 1.0 bad actor roots its own card first,
+ exactly as before this pass. The alias is what lets the walk reach the leaf when the leaf is a leaf. */
+ var low = BadActor(11, severity: 0.3);
+ var high = BadActor(7, severity: 0.45);
+
+ var stories = new InferenceEngine(graph).BuildStories([spill, low, high]);
+
+ var spillStory = Assert.Single(stories, s => s.RootFactKey == PgTargetFactKeys.TempSpill);
+ Assert.Equal([PgTargetFactKeys.TempSpill, high.Key], spillStory.Path);
+ /* The alias never appears in a path; the stored edge keeps it (no mutation of the singleton's edges). */
+ Assert.DoesNotContain(stories, s => s.Path.Contains(PgTargetFactKeys.BadActorFamily));
+ var stored = Assert.Single(graph.GetAllEdges(PgTargetFactKeys.TempSpill), e => PgTargetRelationshipGraph.IsBadActorAlias(e.Destination));
+ Assert.Equal(PgTargetFactKeys.BadActorFamily, stored.Destination);
+
+ /* And a second call over a different fact set resolves afresh — the copy, not the stored edge, carried
+ the first answer. */
+ var other = BadActor(99, severity: 0.9);
+ var active = graph.GetActiveEdges(PgTargetFactKeys.TempSpill, Lookup(spill, other));
+ Assert.Equal(other.Key, Assert.Single(active, e => e.Source == PgTargetFactKeys.TempSpill && e.Destination.StartsWith(PgTargetFactKeys.BadActorKeyPrefix, StringComparison.Ordinal)).Destination);
+ }
+
+ [Fact]
+ public void AnEdgeIntoTheAlias_IsDroppedNotThrown_WhenNoBadActorIsPresent()
+ {
+ var graph = GraphWithAliasEdge();
+ var spill = Spill(0.6);
+
+ var active = graph.GetActiveEdges(PgTargetFactKeys.TempSpill, Lookup(spill));
+ Assert.DoesNotContain(active, e => PgTargetRelationshipGraph.IsBadActorAlias(e.Destination));
+ Assert.DoesNotContain(active, e => e.Destination.StartsWith(PgTargetFactKeys.BadActorKeyPrefix, StringComparison.Ordinal));
+
+ var stories = new InferenceEngine(graph).BuildStories([spill]);
+ var story = Assert.Single(stories, s => s.RootFactKey == PgTargetFactKeys.TempSpill);
+ Assert.Equal([PgTargetFactKeys.TempSpill], story.Path);
+ }
+
+ [Fact]
+ public void NonAliasEdges_PassThroughAsTheSameInstances_AndTheSqlServerGraphIsNotVirtualised()
+ {
+ var graph = new PgTargetRelationshipGraph();
+ var knob = new Fact { Source = PgTargetSources.ConfigSource, Key = PgTargetFactKeys.ConfigWorkMem, Severity = 0.6, BaseSeverity = 0.4 };
+ var spill = Spill(0.6);
+ var stored = graph.GetAllEdges(PgTargetFactKeys.TempSpill);
+ var active = graph.GetActiveEdges(PgTargetFactKeys.TempSpill, Lookup(spill, knob));
+ Assert.All(active, e => Assert.Contains(e, stored));
+
+ /* The seam: GetActiveEdges is virtual on the base and overridden only here; the SQL Server graph's own
+ class does not override it, so that graph's behaviour is the base body's, byte for byte. */
+ var baseMethod = typeof(RelationshipGraph).GetMethod("GetActiveEdges", BindingFlags.Instance | BindingFlags.Public)!;
+ Assert.True(baseMethod.IsVirtual);
+ Assert.Equal(typeof(PgTargetRelationshipGraph), typeof(PgTargetRelationshipGraph).GetMethod("GetActiveEdges", BindingFlags.Instance | BindingFlags.Public)!.DeclaringType);
+ Assert.Equal(typeof(RelationshipGraph), typeof(RelationshipGraph).GetMethod("GetActiveEdges", BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)!.DeclaringType);
+ }
+
+ /* ───────────────────────── item 2: the compare_analysis identity prefix stays SQL Server's ───────────────────────── */
+
+ [Fact]
+ public void APgBadActor_TakesThePresencePath_InCompareAnalysis()
+ {
+ Assert.Equal("BAD_ACTOR_", ComparisonBanding.PlanCacheIdentityPrefix);
+ Assert.False(ComparisonBanding.IsPlanCacheIdentityKey(PgTargetFactKeys.BadActorKey(42)));
+ Assert.True(ComparisonBanding.IsPlanCacheIdentityKey("BAD_ACTOR_0x1234"));
+ }
+
+ /* ───────────────────────── item 3: autovacuum = off is a posture fact in the 0.9 band ───────────────────────── */
+
+ [Fact]
+ public void AutovacuumOff_ScoresThePostureBand_AndOnScoresZero()
+ {
+ Assert.Equal(0.9, PgTargetScorer.AutovacuumOffPostureSeverity);
+ var off = Config(PgTargetFactKeys.ConfigAutovacuumOff, 1);
+ Assert.Equal(0.9, PgTargetScorer.ScoreBase(off));
+ Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigAutovacuumOff, 0)));
+ /* Engine-defined / posture, not a chosen number: no unmeasured stamp. */
+ Assert.False(off.Metadata.ContainsKey("threshold_lineage"));
+ Assert.True(PgTargetFactKeys.IsConfigAdvisoryRoot(PgTargetFactKeys.ConfigAutovacuumOff));
+ }
+
+ [Fact]
+ public void AutovacuumOff_RootsAloneAbove_TheIncidentLine_AndTheBacklogCoFireLiftsIt()
+ {
+ var off = Config(PgTargetFactKeys.ConfigAutovacuumOff, 1);
+ new FactScorer().ScoreAll([off]);
+ Assert.Equal(0.9, off.Severity, precision: 6);
+
+ var fired = Config(PgTargetFactKeys.ConfigAutovacuumOff, 1);
+ var backlog = Backlog(5.0, 4);
+ new FactScorer().ScoreAll([fired, backlog]);
+ Assert.True(backlog.BaseSeverity > 0, "the fixture backlog must fire for the co-fire to be tested");
+ Assert.Equal(0.9 * 1.5, fired.Severity, precision: 6);
+ Assert.Contains(fired.AmplifierResults, a => a.Matched && a.Description.StartsWith("PG_AUTOVACUUM_BACKLOG co-fired", StringComparison.Ordinal));
+ }
+
+ /* ───────────────────────── item 10: maintenance_work_mem is evidence-gated, the work_mem shape ───────────────────────── */
+
+ [Fact]
+ public void MaintWorkMem_IsZeroWithoutTheBacklogStamp_AndTheAdvisoryBaseAtOrPastTheTriggerLine()
+ {
+ Assert.Equal("autovacuum_backlog_ratio", PgTargetScorer.MaintWorkMemBacklogRatioKey);
+ Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigMaintWorkMem, 64)));
+
+ var under = Config(PgTargetFactKeys.ConfigMaintWorkMem, 64);
+ under.Metadata[PgTargetScorer.MaintWorkMemBacklogRatioKey] = 0.5;
+ Assert.Equal(0.0, PgTargetScorer.ScoreBase(under));
+
+ var at = Config(PgTargetFactKeys.ConfigMaintWorkMem, 64);
+ at.Metadata[PgTargetScorer.MaintWorkMemBacklogRatioKey] = 1.0;
+ Assert.Equal(PgTargetScorer.ConfigAdvisoryBase, PgTargetScorer.ScoreBase(at));
+ /* Engine-defined bar (the table's own trigger line): no unmeasured stamp. */
+ Assert.False(at.Metadata.ContainsKey("threshold_lineage"));
+ /* D5: never an advisory root — alone it roots nothing even when stamped. */
+ Assert.False(PgTargetFactKeys.IsConfigAdvisoryRoot(PgTargetFactKeys.ConfigMaintWorkMem));
+ }
+
+ [Fact]
+ public void MaintWorkMem_IsLiftedPastTheIncidentLine_OnlyBesideTheBacklogCoFire()
+ {
+ /* Stamped and the backlog fired: 0.4 × (1 + 0.5) = 0.6 — the vacuum chain's edge into the knob now has a
+ destination that fired. */
+ var lifted = Config(PgTargetFactKeys.ConfigMaintWorkMem, 64);
+ lifted.Metadata[PgTargetScorer.MaintWorkMemBacklogRatioKey] = 5.0;
+ var backlog = Backlog(5.0, 4);
+ new FactScorer().ScoreAll([lifted, backlog]);
+ Assert.Equal(0.6, lifted.Severity, precision: 6);
+
+ /* Stamped but the backlog did NOT fire (not persistent enough): the base stands alone below the line. */
+ var alone = Config(PgTargetFactKeys.ConfigMaintWorkMem, 64);
+ alone.Metadata[PgTargetScorer.MaintWorkMemBacklogRatioKey] = 5.0;
+ var flicker = Backlog(5.0, trailing: 1);
+ new FactScorer().ScoreAll([alone, flicker]);
+ Assert.Equal(0.0, flicker.BaseSeverity);
+ Assert.Equal(0.4, alone.Severity, precision: 6);
+
+ /* Unstamped beside a fired backlog: base 0, and FactScorer skips amplifiers on a base of 0 — the exact
+ inertness this arm ends. */
+ var inert = Config(PgTargetFactKeys.ConfigMaintWorkMem, 64);
+ new FactScorer().ScoreAll([inert, Backlog(5.0, 4)]);
+ Assert.Equal(0.0, inert.Severity);
+
+ /* And the collector is where the stamp comes from — off the config fact, the seam Database.cs uses. */
+ var collector = CSharpSourceWalker.StripCommentsAndStrings(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgTargetFactCollector.Vacuum.cs"));
+ Assert.Contains("facts.Find(f => f.Key == PgTargetFactKeys.ConfigMaintWorkMem)", collector, StringComparison.Ordinal);
+ Assert.Contains("Metadata[PgTargetScorer.MaintWorkMemBacklogRatioKey] = ratio", collector, StringComparison.Ordinal);
+ }
+
+ /* ───────────────────────── item 6: reserved_connections in the ceiling ───────────────────────── */
+
+ [Fact]
+ public void ReservedConnections_IsInTheSnapshotList_HasAKey_IsContext_AndTheCeilingSubtractsItWhenPresent()
+ {
+ Assert.Equal("CONFIG_PG_RESERVED_CONNECTIONS", PgTargetFactKeys.ConfigReservedConnections);
+ Assert.Contains("'reserved_connections'", PgTargetFactCollector.PgTargetConfigSnapshotSql, StringComparison.Ordinal);
+ Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigReservedConnections, 2)));
+ Assert.False(PgTargetFactKeys.IsConfigAdvisoryRoot(PgTargetFactKeys.ConfigReservedConnections));
+
+ var config = CSharpSourceWalker.StripCommentsAndStrings(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgTargetFactCollector.Config.cs"));
+ Assert.Contains("PgTargetFactKeys.ConfigReservedConnections", config, StringComparison.Ordinal);
+
+ /* The ceiling line: the third fact is optional (pre-16: absent → 0) and subtracted alongside the two
+ mandatory ones; the mandatory pair still gates emission on its own. */
+ var sessions = CSharpSourceWalker.StripCommentsAndStrings(RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgTargetFactCollector.Sessions.cs"));
+ Assert.Contains("facts.Find(f => f.Key == PgTargetFactKeys.ConfigReservedConnections)?.Value ?? 0", sessions, StringComparison.Ordinal);
+ Assert.Contains("var usable = maxConnections.Value - reserved.Value - reservedForRole;", sessions, StringComparison.Ordinal);
+ Assert.Contains("if (maxConnections is null || reserved is null)", sessions, StringComparison.Ordinal);
+ Assert.Contains("pg_use_reserved_connections", RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Analysis", "PgTargetFactCollector.Sessions.cs"), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void TheSaturationAdvice_StatesTheThirdCarveOut_OnlyWhenItIsNonZero()
+ {
+ /* Without the setting (every pre-16 target, and the 16+ default): the sentence is byte-identical to lane 3's. */
+ var without = Saturation(peak: 90, maxConnections: 100, superuserReserved: 3, reservedConnections: 0);
+ var block = FactAdvice.Compose(PgTargetFactKeys.ConnectionSaturation, Lookup(without))!;
+ Assert.Contains("90 sessions were connected against 97 usable connections — max_connections 100 minus superuser_reserved_connections 3 — that is 90 / (100 − 3) = 93%.", block.Investigation, StringComparison.Ordinal);
+ Assert.DoesNotContain("reserved_connections 0", block.Investigation, StringComparison.Ordinal);
+
+ /* With it: the arithmetic the advice shows is the arithmetic the ceiling did. */
+ var with = Saturation(peak: 90, maxConnections: 100, superuserReserved: 3, reservedConnections: 2);
+ block = FactAdvice.Compose(PgTargetFactKeys.ConnectionSaturation, Lookup(with))!;
+ Assert.Contains("90 sessions were connected against 95 usable connections — max_connections 100 minus superuser_reserved_connections 3 minus reserved_connections 2 — that is 90 / (100 − 3 − 2) = 95%.", block.Investigation, StringComparison.Ordinal);
+ }
+
+ /* ───────────────────────── item 8: the bad actor's first next-read ───────────────────────── */
+
+ [Fact]
+ public void TheBadActorNextReads_LeadWithTheDurationTrend_WhichIsARegisteredTool()
+ {
+ var recommendations = PgTargetToolRecommendations.GetForKey(PgTargetFactKeys.BadActorKey(42))!;
+ Assert.Equal("get_pg_query_duration_trend", recommendations[0].Tool);
+ Assert.Contains("first question", recommendations[0].Reason, StringComparison.Ordinal);
+ var registered = typeof(DarlingMcpTools).Assembly.GetTypes()
+ .Where(t => t.GetCustomAttribute() is not null)
+ .SelectMany(t => t.GetMethods(BindingFlags.Public | BindingFlags.Static))
+ .Select(m => m.GetCustomAttribute()?.Name)
+ .Where(n => n is not null)
+ .ToHashSet(StringComparer.Ordinal);
+ Assert.Contains("get_pg_query_duration_trend", registered);
+ /* The advice names it as the first question — the recommendation and the prose agree. */
+ Assert.Contains("get_pg_query_duration_trend", PgTargetAdvice.Static(PgTargetFactKeys.BadActorKey(42))!.Remediation, StringComparison.Ordinal);
+ }
+
+ /* ───────────────────────── item 11: the parked hook names the shipped trend key ───────────────────────── */
+
+ [Fact]
+ public void TheParkedOfferedVsDeliveredHook_NamesTpsTrendKey_NotALiteral()
+ {
+ var sessions = RepoFile.ReadRepoFile("PerformanceMonitor.Analysis", "PgTargetScorer.Sessions.cs");
+ Assert.Contains("tps.Metadata.GetValueOrDefault(TpsTrendKey) <= 0", sessions, StringComparison.Ordinal);
+ Assert.DoesNotContain("GetValueOrDefault(\"trend\")", sessions, StringComparison.Ordinal);
+ Assert.Equal("tps_trend", PgTargetScorer.TpsTrendKey);
+ }
+
+ /* ───────────────────────── fixtures ───────────────────────── */
+
+ /// A with one edge INTO the alias, registered through the
+ /// protected AddEdge the content lanes use — the shape lane 6 / lane 9 will write, arranged here because
+ /// no shipped chain names the alias yet.
+ private static PgTargetRelationshipGraph GraphWithAliasEdge()
+ {
+ var graph = new PgTargetRelationshipGraph();
+ var addEdge = typeof(RelationshipGraph).GetMethod("AddEdge", BindingFlags.Instance | BindingFlags.NonPublic)!;
+ addEdge.Invoke(graph,
+ [
+ PgTargetFactKeys.TempSpill, PgTargetFactKeys.BadActorFamily, "temp_spill",
+ "a top statement of the window wrote temp blocks",
+ (Func, bool>)(_ => true),
+ ]);
+ return graph;
+ }
+
+ private static IReadOnlyDictionary Lookup(params Fact[] facts) => facts.ToFactLookup();
+
+ private static Fact Config(string key, double value) =>
+ new() { Source = PgTargetSources.ConfigSource, Key = key, Value = value, ServerId = 1 };
+
+ private static Fact BadActor(long queryId, double severity) => new()
+ {
+ Source = PgTargetSources.QueriesSource,
+ Key = PgTargetFactKeys.BadActorKey(queryId),
+ Value = severity,
+ ServerId = 1,
+ BaseSeverity = severity,
+ Severity = severity,
+ };
+
+ private static Fact Spill(double severity) => new()
+ {
+ Source = PgTargetSources.TempSource,
+ Key = PgTargetFactKeys.TempSpill,
+ Value = severity,
+ ServerId = 1,
+ BaseSeverity = severity,
+ Severity = severity,
+ };
+
+ private static Fact Backlog(double ratio, int trailing) => new()
+ {
+ Source = PgTargetSources.VacuumSource,
+ Key = PgTargetFactKeys.AutovacuumBacklog,
+ Value = ratio,
+ ServerId = 1,
+ Metadata =
+ {
+ [PgTargetScorer.BacklogRatioKey] = ratio,
+ [PgTargetScorer.BacklogTrailingSamplesKey] = trailing,
+ [PgTargetScorer.BacklogSamplesInWindowKey] = 5,
+ [PgTargetScorer.BacklogTablesKey] = 1,
+ },
+ };
+
+ private static Fact Saturation(double peak, double maxConnections, double superuserReserved, double reservedConnections)
+ {
+ var usable = maxConnections - superuserReserved - reservedConnections;
+ return new Fact
+ {
+ Source = PgTargetSources.SessionsSource,
+ Key = PgTargetFactKeys.ConnectionSaturation,
+ Value = peak / usable,
+ ServerId = 1,
+ Metadata =
+ {
+ ["saturation_ratio"] = peak / usable,
+ ["peak_total_sessions"] = peak,
+ ["peak_active_sessions"] = 60,
+ ["peak_idle_in_transaction_sessions"] = 10,
+ ["peak_other_sessions"] = peak - 70,
+ ["peak_idle_in_transaction_share"] = 10 / peak,
+ ["latest_total_sessions"] = peak,
+ ["max_connections"] = maxConnections,
+ ["superuser_reserved_connections"] = superuserReserved,
+ ["reserved_connections"] = reservedConnections,
+ ["usable_connections"] = usable,
+ ["captures_with_rows"] = 240,
+ ["rows_redacted_share"] = 0,
+ },
+ };
+ }
+}
diff --git a/Darling/Darling.Tests/PgTargetFactCollectorTests.cs b/Darling/Darling.Tests/PgTargetFactCollectorTests.cs
index fe43809297..266425b077 100644
--- a/Darling/Darling.Tests/PgTargetFactCollectorTests.cs
+++ b/Darling/Darling.Tests/PgTargetFactCollectorTests.cs
@@ -183,6 +183,11 @@ public void AllSql_EveryFromJoinTarget_ResolvesToACollectorTable_TheRegistry_OrA
{
var tables = CollectorCatalog.All.Select(s => s.TargetTable).ToHashSet(StringComparer.Ordinal);
tables.Add("servers");
+ /* One admission beyond the collector tables and the registry (#3542, between waves): the xmin-horizon arm
+ of the vacuum family (#3537 / #3642's honest denominator) counts SUCCESS captures from collection_log,
+ because "how many minutes of the window did the collector actually observe" is a question only the log
+ answers. Not a v_ view, not a SQL Server table by another name; nothing else is admitted. */
+ tables.Add("collection_log");
foreach (var sql in PgTargetFactCollector.AllSql)
{
diff --git a/Darling/Darling.Tests/PgTargetKnobsTests.cs b/Darling/Darling.Tests/PgTargetKnobsTests.cs
index 07f595675e..a52ff1cfe3 100644
--- a/Darling/Darling.Tests/PgTargetKnobsTests.cs
+++ b/Darling/Darling.Tests/PgTargetKnobsTests.cs
@@ -86,9 +86,12 @@ public void TheConventionChecks_ScoreExactlyAtTheirCompiledDefaults_AndTheContex
Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigMaxConnections, 100)));
Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigSuperuserReserved, 3)));
Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ServerMajorVersion, 18)));
- /* Other lanes' keys fall to 0 through this arm until they fill it. */
+ /* The evidence-gated keys are 0 without their stamp (D5); the vacuum family's posture arm is graded in
+ PgTargetBetweenWavesTests. */
Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigWorkMem, 4)));
- Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigAutovacuumOff, 1)));
+ Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigMaintWorkMem, 64)));
+ Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigAutovacuumOff, 0)));
+ Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config(PgTargetFactKeys.ConfigReservedConnections, 2)));
Assert.Equal(0.0, PgTargetScorer.ScoreBase(Config("PG_PROBE", 99)));
}
@@ -611,7 +614,7 @@ public void TheConfigRead_IsTheShippedReadersShape_AnchoredAtOrBeforeTheWindowEn
{
"shared_buffers", "max_wal_size", "min_wal_size", "effective_cache_size", "random_page_cost", "track_io_timing",
"checkpoint_timeout", "checkpoint_completion_target", "wal_compression", "bgwriter_delay", "bgwriter_lru_maxpages",
- "max_connections", "superuser_reserved_connections", "work_mem", "maintenance_work_mem", "autovacuum",
+ "max_connections", "superuser_reserved_connections", "reserved_connections", "work_mem", "maintenance_work_mem", "autovacuum",
})
{
Assert.Contains($"'{name}'", sql, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs b/Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs
index 0627a0f02e..39fc03f751 100644
--- a/Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs
+++ b/Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs
@@ -41,12 +41,14 @@ public void EveryPgRecommendation_NamesARegisteredTool_AndOnlyPgOrAnalysisReads(
{
Assert.True(RegisteredTools.Count > 40, "the tool sweep found too few registered tools");
- /* The fixed keys (the *Prefix consts are routing vocabulary, not keys a story can carry) plus one
- instance of each dynamic family. */
+ /* The fixed keys (the *Prefix consts are routing vocabulary, not keys a story can carry; the bad-actor
+ ALIAS is an edge destination the PG graph resolves before any path is built, so no story carries it
+ either) plus one instance of each dynamic family. */
var keys = typeof(PgTargetFactKeys).GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string) && !f.Name.EndsWith("Prefix", StringComparison.Ordinal))
.Select(f => (string)f.GetRawConstantValue()!)
.Where(PgTargetFactKeys.IsPgKey)
+ .Where(k => !PgTargetRelationshipGraph.IsBadActorAlias(k))
.Append(PgTargetFactKeys.WaitKey("Lock", "relation"))
.Append(PgTargetFactKeys.BadActorKey(42))
.ToList();
diff --git a/Darling/Darling.Tests/PgTargetQueriesTests.cs b/Darling/Darling.Tests/PgTargetQueriesTests.cs
index 8d74250f67..554cf311a2 100644
--- a/Darling/Darling.Tests/PgTargetQueriesTests.cs
+++ b/Darling/Darling.Tests/PgTargetQueriesTests.cs
@@ -322,9 +322,11 @@ public void TheDrillDown_JoinsTheTextForDisplayAndHash_ParsesTheQueryidFromTheKe
[Fact]
public void TheNextReads_ForABadActor_AreTheStatementAndPlanTools()
{
+ /* The duration trend leads since the between-waves pass: the advice's "first question" (mean stepped vs
+ calls changed) is the read that answers it. */
var recommendations = PgTargetToolRecommendations.GetForKey(PgTargetFactKeys.BadActorKey(HeavyQueryId));
Assert.NotNull(recommendations);
- Assert.Equal(new[] { "get_pg_top_queries", "get_pg_plans" }, recommendations!.Select(r => r.Tool).ToArray());
+ Assert.Equal(new[] { "get_pg_query_duration_trend", "get_pg_top_queries", "get_pg_plans" }, recommendations!.Select(r => r.Tool).ToArray());
}
/* ── gated: the exit criterion ── */
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Config.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Config.cs
index 02fe402d0a..62ed4af7ad 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Config.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Config.cs
@@ -25,9 +25,10 @@ public sealed partial class PgTargetFactCollector
/// context (checkpoint_timeout, checkpoint_completion_target, min_wal_size,
/// wal_compression), the bgwriter knobs the buffer composite reads beside shared_buffers
/// (bgwriter_delay, bgwriter_lru_maxpages), and the keys other lanes score from a fact this
- /// read emits (max_connections / superuser_reserved_connections for lane 3's ceiling,
- /// work_mem for lane 6, maintenance_work_mem and autovacuum for lane 4). $1
- /// server_id, $2 window end.
+ /// read emits (max_connections / superuser_reserved_connections / reserved_connections
+ /// for lane 3's ceiling — the last a PostgreSQL 16+ name, absent from the snapshot before 16 and then simply
+ /// not emitted, so the ceiling subtracts 0; work_mem for lane 6, maintenance_work_mem and
+ /// autovacuum for lane 4). $1 server_id, $2 window end.
///
/// The shape is DarlingPgServerConfigReader.CurrentConfigSql's, with two deliberate
/// differences. The snapshot is anchored on MAX(collection_time) AT OR BEFORE $2 rather than
@@ -67,7 +68,7 @@ AND c.name IN (
'shared_buffers', 'max_wal_size', 'min_wal_size', 'effective_cache_size', 'random_page_cost',
'track_io_timing', 'checkpoint_timeout', 'checkpoint_completion_target', 'wal_compression',
'bgwriter_delay', 'bgwriter_lru_maxpages',
- 'max_connections', 'superuser_reserved_connections',
+ 'max_connections', 'superuser_reserved_connections', 'reserved_connections',
'work_mem', 'maintenance_work_mem', 'autovacuum')";
/// One pg_settings row as the snapshot stores it: raw text plus its unit.
@@ -84,10 +85,11 @@ AND c.name IN (
///
/// What scores and what is context. The two knobs and the three convention checks score
/// 0.4-when-bad in PgTargetScorer.Config.cs and root advisory cards (D5). checkpoint_timeout,
- /// wal_compression, max_connections and superuser_reserved_connections are CONTEXT
- /// (base 0): the write chain's advice reads the first two from the full fact set to say "checkpoint_timeout
- /// is 5 min, the default" beside a checkpoint-pressure finding — the design's "defaults noted alongside
- /// checkpoint-pressure findings only" — and lane 3's saturation fact reads the last two at score time.
+ /// wal_compression, max_connections, superuser_reserved_connections and
+ /// reserved_connections are CONTEXT (base 0): the write chain's advice reads the first two from the
+ /// full fact set to say "checkpoint_timeout is 5 min, the default" beside a checkpoint-pressure finding — the
+ /// design's "defaults noted alongside checkpoint-pressure findings only" — and lane 3's ceiling reads the
+ /// last three at collect time.
/// work_mem, maintenance_work_mem and autovacuum are emitted here and scored by lanes
/// 6 and 4; until those land they score 0 through the stub and are context.
///
@@ -199,6 +201,11 @@ with which codec. */
if (TryNumber(settings, "superuser_reserved_connections", out var reserved, out var srRow))
facts.Add(ConfigFact(context, PgTargetFactKeys.ConfigSuperuserReserved, reserved, srRow, snapshotAgeSeconds));
+ /* PostgreSQL 16+ only (pg_use_reserved_connections); a pre-16 snapshot has no such row and the fact is
+ simply not emitted — the ceiling treats absence as 0, which is also the 16+ default. */
+ if (TryNumber(settings, "reserved_connections", out var reservedForRole, out var rcRow))
+ facts.Add(ConfigFact(context, PgTargetFactKeys.ConfigReservedConnections, reservedForRole, rcRow, snapshotAgeSeconds));
+
/* ── Lanes 6 and 4 score these; emitted here because the read already has them. ── */
if (TryBytes(settings, "work_mem", out var workMem, out var wmRow))
{
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Sessions.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Sessions.cs
index 9596a6d5e5..945d0d0c45 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Sessions.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Sessions.cs
@@ -125,12 +125,14 @@ CROSS JOIN peak AS p
/// scoring. The three numbers ride the fact as metadata so the advice states them and a reader of
/// get_analysis_facts can redo the division.
///
- /// usable = max_connections − superuser_reserved_connections, engine-defined: PostgreSQL refuses
- /// the connection that would take the last superuser_reserved_connections slots unless the role is a
- /// superuser, so an ordinary application sees the cliff there, not at max_connections. PostgreSQL 16
- /// added reserved_connections (slots for pg_use_reserved_connections members, default 0) as a
- /// second carve-out; lane 2's snapshot name list does not carry it yet, so on a target that set it this
- /// ceiling reads that many slots HIGH — noted, not compensated.
+ /// usable = max_connections − superuser_reserved_connections − reserved_connections, engine-defined:
+ /// PostgreSQL refuses the connection that would take the last superuser_reserved_connections slots
+ /// unless the role is a superuser, so an ordinary application sees the cliff there, not at
+ /// max_connections. PostgreSQL 16 added reserved_connections (slots held for members of
+ /// pg_use_reserved_connections, default 0) as a second carve-out ahead of the superuser one, and an
+ /// ordinary role hits that cliff first; the snapshot name list carries it since the between-waves pass, so
+ /// the third context fact is subtracted when present and 0 when absent (a pre-16 target has no such setting,
+ /// which is the same arithmetic as the 16+ default).
///
/// Precedence: redaction first. A majority-redacted window emits the permissions advisory and
/// returns — no saturation fact, no ratio — because under redaction the state columns and the collector's own
@@ -211,9 +213,12 @@ private async partial Task CollectSessionFactsAsync(AnalysisContext context, Lis
return;
}
- /* ── The ceiling, off lane 2's two context facts (emission order: Config before Sessions). ── */
+ /* ── The ceiling, off lane 2's context facts (emission order: Config before Sessions). The third is
+ PostgreSQL 16+'s reserved_connections (pg_use_reserved_connections); absent on a pre-16 snapshot and
+ then 0, so the two mandatory facts alone still make a ceiling. ── */
var maxConnections = facts.Find(f => f.Key == PgTargetFactKeys.ConfigMaxConnections);
var reserved = facts.Find(f => f.Key == PgTargetFactKeys.ConfigSuperuserReserved);
+ var reservedForRole = facts.Find(f => f.Key == PgTargetFactKeys.ConfigReservedConnections)?.Value ?? 0;
if (maxConnections is null || reserved is null)
{
_logger?.LogDebug(
@@ -222,9 +227,9 @@ private async partial Task CollectSessionFactsAsync(AnalysisContext context, Lis
return;
}
- var usable = maxConnections.Value - reserved.Value;
- /* PostgreSQL refuses to start with superuser_reserved_connections >= max_connections, so a
- non-positive ceiling is a snapshot that does not describe a running server; no claim. */
+ var usable = maxConnections.Value - reserved.Value - reservedForRole;
+ /* PostgreSQL refuses to start with superuser_reserved_connections + reserved_connections >= max_connections,
+ so a non-positive ceiling is a snapshot that does not describe a running server; no claim. */
if (usable <= 0)
return;
@@ -250,6 +255,7 @@ private async partial Task CollectSessionFactsAsync(AnalysisContext context, Lis
["latest_idle_in_transaction_sessions"] = latestIdleInTransaction,
["max_connections"] = maxConnections.Value,
["superuser_reserved_connections"] = reserved.Value,
+ ["reserved_connections"] = reservedForRole,
["usable_connections"] = usable,
["max_connections_pending_restart"] = maxConnections.Metadata.GetValueOrDefault("pending_restart"),
["config_snapshot_age_s"] = maxConnections.Metadata.GetValueOrDefault("snapshot_age_s"),
diff --git a/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Vacuum.cs b/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Vacuum.cs
index e17d8dcf83..d10a91e15c 100644
--- a/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Vacuum.cs
+++ b/Darling/PerformanceMonitor.Darling.Analysis/PgTargetFactCollector.Vacuum.cs
@@ -358,6 +358,15 @@ A run whose samples share a timestamp has no span to divide by and says so. */
if (lastAutovacuum.HasValue)
fact.Metadata[PgTargetScorer.BacklogHoursSinceLastAutovacuumKey] = (AsNaive(context.TimeRangeEnd) - lastAutovacuum.Value).TotalHours;
+ /* The D5 stamp onto the knob, off the config fact emitted a moment ago (emission order: Config before
+ Vacuum) — the same seam PgTargetFactCollector.Database.cs uses to stamp the spill rate onto work_mem.
+ CONFIG_PG_MAINT_WORK_MEM's source is pg_config, so its base comes from ScoreConfigFact, which receives
+ one fact and no lookup; the backlog ratio has to be ON the fact for the knob to score anything
+ (PgTargetScorer.Config.cs, ScoreConfigMaintWorkMem). Absent (no backlog fact) the knob stays context. */
+ var maintWorkMem = facts.Find(f => f.Key == PgTargetFactKeys.ConfigMaintWorkMem);
+ if (maintWorkMem is not null)
+ maintWorkMem.Metadata[PgTargetScorer.MaintWorkMemBacklogRatioKey] = ratio;
+
facts.Add(fact);
}
catch (Exception ex) when (!AnalysisShutdown.IsExpectedAbandon(ex, context.CancellationToken))
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs
index 36e62c2f72..5daa4dbf30 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/PgTargetToolRecommendations.cs
@@ -44,6 +44,7 @@ internal static class PgTargetToolRecommendations
[PgTargetFactKeys.ConfigAutovacuumOff] = ConfigReads(),
[PgTargetFactKeys.ConfigMaxConnections] = ConfigReads(),
[PgTargetFactKeys.ConfigSuperuserReserved] = ConfigReads(),
+ [PgTargetFactKeys.ConfigReservedConnections] = ConfigReads(),
[PgTargetFactKeys.ConfigWorkMem] = ConfigReads(),
[PgTargetFactKeys.ConfigMaintWorkMem] = ConfigReads(),
[PgTargetFactKeys.ConfigStatStatementsMissing] =
@@ -145,8 +146,11 @@ internal static class PgTargetToolRecommendations
if (key.StartsWith(PgTargetFactKeys.BadActorKeyPrefix, StringComparison.Ordinal))
{
+ /* The advice (PgTargetAdvice.Queries.cs) ends on "which is the first question": whether the mean STEPPED
+ (a plan change) or the calls did (a workload change). The read that answers it leads. */
return
[
+ new("get_pg_query_duration_trend", "Whether this statement's mean execution time stepped or its call count did — the first question"),
new("get_pg_top_queries", "This statement's deltas beside the rest of the window's top statements"),
new("get_pg_plans", "Captured plans for the statement, when plan capture is configured"),
];
diff --git a/PerformanceMonitor.Analysis/ComparisonBanding.cs b/PerformanceMonitor.Analysis/ComparisonBanding.cs
index e46dbc1516..96a30f66bc 100644
--- a/PerformanceMonitor.Analysis/ComparisonBanding.cs
+++ b/PerformanceMonitor.Analysis/ComparisonBanding.cs
@@ -120,7 +120,18 @@ public static class ComparisonBanding
public const string PresenceComparisonOnly = "comparison_only";
public const string PresenceBaselineOnly = "baseline_only";
- /// The BAD_ACTOR_<query_hash> family — per-query identity, reported as churn.
+ /// The BAD_ACTOR_<query_hash> family — per-query identity, reported as churn.
+ ///
+ /// Deliberately NOT PG_BAD_ACTOR_ (#3542, decided between waves). The PostgreSQL twin is keyed
+ /// on pg_stat_statements.queryid, which is stable for the life of a PostgreSQL major (it changes only
+ /// across a major upgrade or a compute_query_id change — the advice says so), whereas SQL Server's
+ /// query_hash is a plan-cache identity that a recompile can re-key inside one window. So for a
+ /// PostgreSQL bad actor the key-set arithmetic is HONEST: a statement present in one window and absent from
+ /// the other did start or stop being a top consumer (or fell out of the top-5 cut — the one churn source that
+ /// remains, and the presence text is read with that in mind). The churn band would hide a real new offender
+ /// behind "the cache held a different plan", which is not a thing PostgreSQL's identity does. A
+ /// PG_BAD_ACTOR_* key therefore falls through (no shared prefix)
+ /// and takes the presence path.
public const string PlanCacheIdentityPrefix = "BAD_ACTOR_";
///
diff --git a/PerformanceMonitor.Analysis/PgTargetAdvice.Sessions.cs b/PerformanceMonitor.Analysis/PgTargetAdvice.Sessions.cs
index 2efa7c2fa1..af2a9cc764 100644
--- a/PerformanceMonitor.Analysis/PgTargetAdvice.Sessions.cs
+++ b/PerformanceMonitor.Analysis/PgTargetAdvice.Sessions.cs
@@ -111,6 +111,9 @@ private static AdviceBlock ComposeSaturation(Fact fact, IReadOnlyDictionary 0 ? $" minus reserved_connections {reservedForRole:0}" : string.Empty)} — that is {peak:0} / ({maxConnections:0} − {reserved:0}{(reservedForRole > 0 ? $" − {reservedForRole:0}" : string.Empty)}) = {ratio * 100:0}%.");
inv.Append(" PostgreSQL refuses the connection that would take a reserved slot unless the role is a superuser (FATAL: too many clients already / remaining connection slots are reserved), and it does not queue: for the application that asked, a refusal is an outage, not a slowdown.");
inv.Append(CultureInfo.InvariantCulture,
$" State breakdown at the peak: {active:0} active, {idleInTransaction:0} idle in transaction, {other:0} other (idle client sessions and PostgreSQL's own background processes — checkpointer, walwriter, autovacuum — which this series cannot tell apart and which hold no connection slot, so the ratio reads a few points high, the safe direction for a cliff).");
diff --git a/PerformanceMonitor.Analysis/PgTargetFactKeys.cs b/PerformanceMonitor.Analysis/PgTargetFactKeys.cs
index b64a2329d8..ede5a348bb 100644
--- a/PerformanceMonitor.Analysis/PgTargetFactKeys.cs
+++ b/PerformanceMonitor.Analysis/PgTargetFactKeys.cs
@@ -169,6 +169,30 @@ public static class PgTargetFactKeys
public const string BadActorKeyPrefix = "PG_BAD_ACTOR_";
+ /* ── Between-waves shared-vocabulary block (#3542, after wave A). Additions here are delimited so a lane
+ landing concurrently can rebase around one block rather than a scatter. ── */
+
+ ///
+ /// The stable ALIAS an edge names when its destination is "the bad actor" — whichever
+ /// PG_BAD_ACTOR_<queryid> fact the pass emitted. Never a fact's key: no collector stamps it, and
+ /// does not match it (the alias lacks the trailing underscore), so the
+ /// queries scorer, advice and tool-recommendation arms cannot claim it by prefix. It exists because
+ /// takes an exact destination string and this family's keys are
+ /// dynamic (lane 7's note in PgTargetRelationshipGraph.Query.cs): the families whose symptom a
+ /// statement explains (PG_TEMP_SPILL, PG_CPU_PERCENT) declare their edge INTO this alias, and
+ /// resolves it at story-build time to the
+ /// highest-severity bad actor present, or drops the edge when none is. One alias, one resolution rule,
+ /// declared once so lanes 6 and 9 do not each invent one.
+ ///
+ public const string BadActorFamily = "PG_BAD_ACTOR";
+
+ /// The PostgreSQL 16+ second carve-out of connection slots (reserved_connections, for
+ /// members of pg_use_reserved_connections; default 0; absent before 16). Context, base 0, read at
+ /// collect time by lane 3's ceiling beside and
+ /// : usable = max_connections − superuser_reserved_connections −
+ /// reserved_connections.
+ public const string ConfigReservedConnections = "CONFIG_PG_RESERVED_CONNECTIONS";
+
/* ── Lane 8 — durability posture (D6): posture-only, isolated from every amplifier and edge. ── */
public const string PostureFsync = "PG_POSTURE_FSYNC";
@@ -236,7 +260,8 @@ public static string BadActorKey(long queryId) =>
/// default, an extension missing, a durability setting off) may root a card on a quiet server; an
/// EVIDENCE-gated check (work_mem, maintenance_work_mem) may not — it scores above zero
/// only when its workload co-fire exists, and then the co-fire is the root. The context facts
- /// (max_connections, superuser_reserved_connections) score 0 and are not here either.
+ /// (max_connections, superuser_reserved_connections, reserved_connections) score 0 and
+ /// are not here either.
///
/// Pre-declared here rather than left for the lanes so that four parallel content lanes do not
/// each edit this one shared file. A lane whose design moves a key between the two classes edits this
diff --git a/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Query.cs b/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Query.cs
index 7e6727286c..ceb8e4b9a2 100644
--- a/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Query.cs
+++ b/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.Query.cs
@@ -35,7 +35,11 @@ namespace PerformanceMonitor.Analysis;
/// are dynamic (, one per queryid), so a static edge cannot
/// name one. The lane that writes the first edge INTO a bad actor either resolves the destination at
/// build-story time (the top-share bad actor present in the fact set) or declares a stable alias key the
-/// collector also stamps — a shared-vocabulary decision, recorded here so it is made once.
+/// collector also stamps — a shared-vocabulary decision, recorded here so it is made once. Decided between
+/// waves: both halves of the first option — an edge names
+/// (PG_BAD_ACTOR, never a fact's key) and the root graph's GetActiveEdges override resolves it
+/// to the highest-severity bad actor present, or drops the edge. Lanes 6 and 9 write
+/// AddEdge(PG_TEMP_SPILL, PgTargetFactKeys.BadActorFamily, …) and nothing else changes.
///
public sealed partial class PgTargetRelationshipGraph
{
diff --git a/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.cs b/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.cs
index a89df0a276..7b26c3f0db 100644
--- a/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.cs
+++ b/PerformanceMonitor.Analysis/PgTargetRelationshipGraph.cs
@@ -6,6 +6,10 @@
* 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;
+
namespace PerformanceMonitor.Analysis;
///
@@ -24,6 +28,19 @@ namespace PerformanceMonitor.Analysis;
/// The one engine-level rule every edge inherits: an edge's PREDICATE reads the fact set, never a
/// bar of its own. Thresholds live in with their lineage; the graph asks
/// only whether the destination fact fired.
+///
+/// The one resolution this graph performs itself: the bad-actor alias.
+/// takes an exact destination string and InferenceEngine.Traverse looks it up in the fact set by that
+/// string, while the queries family's keys are dynamic (, one per
+/// queryid) — so no static edge can name the statement a spill or a CPU spike leads to (lane 7's
+/// note in PgTargetRelationshipGraph.Query.cs). The lanes that own an edge INTO a bad actor declare it
+/// against , and rewrites that
+/// destination, per call, to the highest-severity PG_BAD_ACTOR_* fact the pass emitted. Two candidates
+/// → the higher (ties by ordinal key, so a pass is deterministic); none → the
+/// edge is dropped from the active set, never thrown, and the story ends where it did before. The rewrite
+/// returns a COPY of the edge — the stored edge keeps the alias, because the graph is a process-wide
+/// singleton walked for many servers and a mutated destination would leak one pass's statement into the
+/// next. The base body is unchanged for every non-alias edge, which is every edge the SQL Server graph has.
///
public sealed partial class PgTargetRelationshipGraph : RelationshipGraph
{
@@ -42,4 +59,74 @@ public PgTargetRelationshipGraph()
private partial void BuildWriteEdges();
private partial void BuildMemoryEdges();
private partial void BuildQueryEdges();
+
+ ///
+ /// The shared active-edge read, with the bad-actor alias resolved — see the class summary. Every edge
+ /// whose destination is not passes through untouched (the
+ /// same instances the base returns); an alias edge is replaced by a copy pointing at
+ /// 's answer, or omitted when there is none.
+ ///
+ public override List GetActiveEdges(string sourceKey, IReadOnlyDictionary factsByKey)
+ {
+ var active = base.GetActiveEdges(sourceKey, factsByKey);
+ if (active.Count == 0 || !active.Any(e => IsBadActorAlias(e.Destination)))
+ return active;
+
+ var resolved = ResolveBadActor(factsByKey);
+ var result = new List(active.Count);
+ foreach (var edge in active)
+ {
+ if (!IsBadActorAlias(edge.Destination))
+ {
+ result.Add(edge);
+ continue;
+ }
+
+ if (resolved is null)
+ continue;
+
+ result.Add(new Edge
+ {
+ Source = edge.Source,
+ Destination = resolved,
+ Category = edge.Category,
+ PredicateDescription = edge.PredicateDescription,
+ Predicate = edge.Predicate,
+ });
+ }
+
+ return result;
+ }
+
+ /// Whether is the alias, exactly — ordinal, because the alias is
+ /// declared upper-case and edges are written against the constant.
+ public static bool IsBadActorAlias(string? destination) =>
+ string.Equals(destination, PgTargetFactKeys.BadActorFamily, StringComparison.Ordinal);
+
+ ///
+ /// The key of the highest-severity PG_BAD_ACTOR_* fact in , or null when
+ /// the pass emitted none. Severity, not base severity: the traversal orders candidates by
+ /// and the alias should land where the walk would have gone had every statement
+ /// been nameable. Ties break on the ordinal key so the same fact set always resolves the same way.
+ ///
+ public static string? ResolveBadActor(IReadOnlyDictionary factsByKey)
+ {
+ string? bestKey = null;
+ Fact? best = null;
+ foreach (var (key, fact) in factsByKey)
+ {
+ if (!key.StartsWith(PgTargetFactKeys.BadActorKeyPrefix, StringComparison.Ordinal))
+ continue;
+
+ if (best is null
+ || fact.Severity > best.Severity
+ || (fact.Severity == best.Severity && string.CompareOrdinal(key, bestKey) < 0))
+ {
+ best = fact;
+ bestKey = key;
+ }
+ }
+
+ return bestKey;
+ }
}
diff --git a/PerformanceMonitor.Analysis/PgTargetScorer.Config.cs b/PerformanceMonitor.Analysis/PgTargetScorer.Config.cs
index 136a570a8a..f915e56fbc 100644
--- a/PerformanceMonitor.Analysis/PgTargetScorer.Config.cs
+++ b/PerformanceMonitor.Analysis/PgTargetScorer.Config.cs
@@ -11,8 +11,13 @@
namespace PerformanceMonitor.Analysis;
///
-/// pg_config — the CONFIG_PG_* setting checks (lane 2 fills the two knobs and the convention /
-/// meta checks; lanes 3, 4 and 6 fill the keys their families own). Pattern: FactScorer.ScoreConfigFact
+/// pg_config — the CONFIG_PG_* setting checks. One SOURCE, four owning families, because the
+/// config snapshot read emits every key and the base-severity seam receives one fact at a time: owner: knobs
+/// (lane 2) — the two knobs and the convention / meta checks; owner: sessions (lane 3) — the connection-ceiling
+/// context keys; owner: vacuum (lane 4) — autovacuum and maintenance_work_mem; owner: temp
+/// (lane 6) — work_mem. Each family's arm is labelled with its owner below, and an arm whose grading is
+/// more than a line delegates to the owner's partial (ScoreConfigWorkMem in PgTargetScorer.Temp.cs)
+/// so the bar and its lineage live beside the evidence it reads. Pattern: FactScorer.ScoreConfigFact
/// — 0.4 ONLY when the setting is bad, so a convention check roots an advisory card and never an incident
/// (D5). is context and stays 0. Every bar carries its
/// lineage marker (see the class summary in PgTargetScorer.cs).
@@ -52,20 +57,43 @@ against this server's WAL rate. */
read is not four times a sequential one. */
public const double RandomPageCostDefault = 4.0;
+ /* engine-defined / posture (owner: vacuum): autovacuum = off is the one server-wide setting whose value alone
+ is a fault, not a convention — with the launcher stopped nothing reclaims dead tuples and nothing advances
+ relfrozenxid, so the wraparound wall is reached by arithmetic. Design §3.1 places it in the 0.9 band: above
+ the 0.5 incident line on its own evidence (the setting IS the evidence, the way pg_posture's fsync = off
+ is), below 1.0 because the backlog co-fire (VacuumConfigCoFireAmplifiers, +0.5) is what says the damage is
+ measured rather than pending. The fact's Value is 0/1 as the collector writes it. */
+ public const double AutovacuumOffPostureSeverity = 0.9;
+
+ ///
+ /// The evidence stamp the vacuum collector writes onto the CONFIG_PG_MAINT_WORK_MEM fact when a
+ /// backlog fact exists: the worst table's dead-or-insert tuples as a multiple of its OWN autovacuum trigger
+ /// line (the backlog fact's ). The knob's base is graded from this, one fact at
+ /// a time — the D5 shape work_mem uses with . Absent when no
+ /// backlog fact was emitted, and then the knob scores 0 and is context.
+ ///
+ public const string MaintWorkMemBacklogRatioKey = "autovacuum_backlog_ratio";
+
///
/// 0.4 when the setting is at (or below) its shipped default and 0 otherwise — a convention check (D5).
/// Context keys (checkpoint_timeout, wal_compression, max_connections,
- /// superuser_reserved_connections, the registry major) score 0 here and are read by the advice
- /// and by lane 3's saturation arm. work_mem is lane 6's arm — evidence-gated (D5), delegated to
- /// ScoreConfigWorkMem in PgTargetScorer.Temp.cs, which grades the spill evidence the collector
- /// stamped onto the fact and is 0 without it. maintenance_work_mem / autovacuum are lane 4's
- /// arms (evidence-gated and the 0.9-band posture respectively) and fall to the default 0 until that lane
- /// fills them, so the fact exists as context and roots nothing.
+ /// superuser_reserved_connections, reserved_connections, the registry major) score 0 here and
+ /// are read by the advice and by the sessions family's saturation arm. work_mem is the temp family's
+ /// arm — evidence-gated (D5), delegated to ScoreConfigWorkMem in PgTargetScorer.Temp.cs, which
+ /// grades the spill evidence the collector stamped onto the fact and is 0 without it.
+ /// maintenance_work_mem is the vacuum family's evidence-gated twin of it (:
+ /// 0 without the backlog stamp, the advisory base with it, lifted past the incident line only by the backlog
+ /// co-fire); autovacuum is the vacuum family's posture arm ().
+ /// Both were inert until the between-waves pass: their source is pg_config, so this is the only
+ /// switch that can give them a base, and FactScorer.ScoreAll skips amplifiers on a base of 0 — no
+ /// co-fire could ever have lifted them.
///
private static partial double ScoreConfigFact(Fact fact)
{
switch (fact.Key)
{
+ /* ── owner: knobs (lane 2) ── */
+
/* engine-defined: the initdb line, SharedBuffersInitdbDefaultMb. Value is megabytes. */
case PgTargetFactKeys.ConfigSharedBuffers:
return fact.Value <= SharedBuffersInitdbDefaultMb ? ConfigAdvisoryBase : 0.0;
@@ -89,16 +117,51 @@ private static partial double ScoreConfigFact(Fact fact)
case PgTargetFactKeys.ConfigTrackIoTiming:
return fact.Value == 0 ? ConfigAdvisoryBase : 0.0;
- /* D5, evidence-gated (lane 6): the value is not graded; the stamped spill evidence is. The arm lives
- in PgTargetScorer.Temp.cs beside the floor it shares with PG_TEMP_SPILL. */
+ /* ── owner: temp (lane 6) ── */
+
+ /* D5, evidence-gated: the value is not graded; the stamped spill evidence is. The arm lives in
+ PgTargetScorer.Temp.cs beside the floor it shares with PG_TEMP_SPILL. */
case PgTargetFactKeys.ConfigWorkMem:
return ScoreConfigWorkMem(fact);
+ /* ── owner: vacuum (lane 4) ── */
+
+ /* engine-defined / posture: AutovacuumOffPostureSeverity when autovacuum = off (Value 1), 0 when on. */
+ case PgTargetFactKeys.ConfigAutovacuumOff:
+ return fact.Value > 0 ? AutovacuumOffPostureSeverity : 0.0;
+
+ /* D5, evidence-gated: the value is not graded; the stamped backlog evidence is. */
+ case PgTargetFactKeys.ConfigMaintWorkMem:
+ return ScoreConfigMaintWorkMem(fact);
+
+ /* ── owner: sessions (lane 3): max_connections / superuser_reserved_connections / reserved_connections
+ are context (base 0) and fall through; the ceiling they compose is graded on the saturation fact. ── */
+
default:
return 0.0;
}
}
+ ///
+ /// The D5 arm for CONFIG_PG_MAINT_WORK_MEM, the vacuum family's twin of ScoreConfigWorkMem: 0
+ /// when the collector stamped no backlog evidence (no PG_AUTOVACUUM_BACKLOG fact this pass — the knob
+ /// is context), the advisory base when the stamped backlog is AT OR PAST the table's own trigger line. That
+ /// base is below the incident line and the key is not an advisory root, so alone it still roots nothing;
+ /// VacuumConfigCoFireAmplifiers' +0.5 on the backlog having FIRED is what carries it to 0.6, and the
+ /// vacuum chain's PG_AUTOVACUUM_BACKLOG → CONFIG_PG_MAINT_WORK_MEM edge then has a destination that
+ /// fired. No threshold_lineage = 0 stamp: the bar below is the engine's, not a chosen number.
+ ///
+ private static double ScoreConfigMaintWorkMem(Fact fact)
+ {
+ if (!fact.Metadata.TryGetValue(MaintWorkMemBacklogRatioKey, out var backlogRatio))
+ return 0.0;
+
+ /* engine-defined: 1.0 is the table's own autovacuum trigger line (autovacuum_vacuum_threshold +
+ autovacuum_vacuum_scale_factor × reltuples, reloptions honoured), the same line the backlog fact's
+ concerning bar sits on — the knob has evidence exactly when autovacuum itself would have acted. */
+ return backlogRatio >= 1.0 ? ConfigAdvisoryBase : 0.0;
+ }
+
///
/// No amplifier for a PURE convention key (effective_cache_size, random_page_cost,
/// track_io_timing, the context keys): D5 says a config value alone roots an advisory and is
diff --git a/PerformanceMonitor.Analysis/PgTargetScorer.Sessions.cs b/PerformanceMonitor.Analysis/PgTargetScorer.Sessions.cs
index 19abcfd9c6..e36c045201 100644
--- a/PerformanceMonitor.Analysis/PgTargetScorer.Sessions.cs
+++ b/PerformanceMonitor.Analysis/PgTargetScorer.Sessions.cs
@@ -174,14 +174,16 @@ private static partial List SessionsAmplifiers(string key)
facts.TryGetValue(PgTargetFactKeys.CpuPercent, out var cpu) && cpu.BaseSeverity > 0,
},
/* v2 — offered vs delivered (design §3.6, D7 verbatim): sessions climbing while PG_TPS is flat or falling.
- Needs a trend on the PG_TPS fact (first-half vs second-half rate) from lane 2's database family:
+ Reads the trend the database family stamps on the PG_TPS fact (second half − first half of the window's
+ rate, PgTargetScorer.TpsTrendKey — shipped by lane 6 in PgTargetScorer.Database.cs); parked until the
+ co-fire is wired and pinned, and the key is named by its constant so the uncomment cannot drift:
new()
{
Description = "Sessions climbed across the window while transactions per second did not — queueing at the cliff",
Boost = ConnectionSaturationCoFireBoost,
Predicate = facts =>
facts.TryGetValue(key, out var self) && self.Metadata.GetValueOrDefault("peak_is_late") > 0
- && facts.TryGetValue(PgTargetFactKeys.Tps, out var tps) && tps.Metadata.GetValueOrDefault("trend") <= 0,
+ && facts.TryGetValue(PgTargetFactKeys.Tps, out var tps) && tps.Metadata.GetValueOrDefault(TpsTrendKey) <= 0,
},
v2 — PG_IDLE_IN_TRANSACTION fired: parked connections with a measured duration and horizon claim,
replacing the self-metadata share arm above:
diff --git a/PerformanceMonitor.Analysis/RelationshipGraph.cs b/PerformanceMonitor.Analysis/RelationshipGraph.cs
index cb28b5235f..eb141d8706 100644
--- a/PerformanceMonitor.Analysis/RelationshipGraph.cs
+++ b/PerformanceMonitor.Analysis/RelationshipGraph.cs
@@ -39,8 +39,11 @@ protected RelationshipGraph(bool buildSqlServerEdges)
///
/// Returns all edges originating from the given fact key,
/// filtered to only those whose predicates are true.
+ /// Virtual (#3542, between waves) for the one derived graph whose edges can name an ALIAS destination
+ /// () that must be resolved against the fact set at
+ /// story-build time; the SQL Server graph never overrides it and this body is byte-for-byte what it was.
///
- public List GetActiveEdges(string sourceKey, IReadOnlyDictionary factsByKey)
+ public virtual List GetActiveEdges(string sourceKey, IReadOnlyDictionary factsByKey)
{
if (!_edges.TryGetValue(sourceKey, out var edges))
return [];