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 [];