Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
370 changes: 370 additions & 0 deletions Darling/Darling.Tests/PgTargetBetweenWavesTests.cs

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Darling/Darling.Tests/PgTargetFactCollectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
9 changes: 6 additions & 3 deletions Darling/Darling.Tests/PgTargetKnobsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}

Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions Darling/Darling.Tests/PgTargetMcpSurfaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion Darling/Darling.Tests/PgTargetQueriesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ── */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ public sealed partial class PgTargetFactCollector
/// context (<c>checkpoint_timeout</c>, <c>checkpoint_completion_target</c>, <c>min_wal_size</c>,
/// <c>wal_compression</c>), the bgwriter knobs the buffer composite reads beside <c>shared_buffers</c>
/// (<c>bgwriter_delay</c>, <c>bgwriter_lru_maxpages</c>), and the keys other lanes score from a fact this
/// read emits (<c>max_connections</c> / <c>superuser_reserved_connections</c> for lane 3's ceiling,
/// <c>work_mem</c> for lane 6, <c>maintenance_work_mem</c> and <c>autovacuum</c> for lane 4). <c>$1</c>
/// server_id, <c>$2</c> window end.
/// read emits (<c>max_connections</c> / <c>superuser_reserved_connections</c> / <c>reserved_connections</c>
/// 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; <c>work_mem</c> for lane 6, <c>maintenance_work_mem</c> and
/// <c>autovacuum</c> for lane 4). <c>$1</c> server_id, <c>$2</c> window end.
///
/// <para><b>The shape is <c>DarlingPgServerConfigReader.CurrentConfigSql</c>'s</b>, with two deliberate
/// differences. The snapshot is anchored on <c>MAX(collection_time)</c> AT OR BEFORE <c>$2</c> rather than
Expand Down Expand Up @@ -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')";

/// <summary>One <c>pg_settings</c> row as the snapshot stores it: raw text plus its unit.</summary>
Expand All @@ -84,10 +85,11 @@ AND c.name IN (
///
/// <para><b>What scores and what is context.</b> The two knobs and the three convention checks score
/// 0.4-when-bad in <c>PgTargetScorer.Config.cs</c> and root advisory cards (D5). <c>checkpoint_timeout</c>,
/// <c>wal_compression</c>, <c>max_connections</c> and <c>superuser_reserved_connections</c> 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.
/// <c>wal_compression</c>, <c>max_connections</c>, <c>superuser_reserved_connections</c> and
/// <c>reserved_connections</c> 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.
/// <c>work_mem</c>, <c>maintenance_work_mem</c> and <c>autovacuum</c> are emitted here and scored by lanes
/// 6 and 4; until those land they score 0 through the stub and are context.</para>
///
Expand Down Expand Up @@ -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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <c>get_analysis_facts</c> can redo the division.</para>
///
/// <para><b>usable = max_connections − superuser_reserved_connections</b>, engine-defined: PostgreSQL refuses
/// the connection that would take the last <c>superuser_reserved_connections</c> slots unless the role is a
/// superuser, so an ordinary application sees the cliff there, not at <c>max_connections</c>. PostgreSQL 16
/// added <c>reserved_connections</c> (slots for <c>pg_use_reserved_connections</c> 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.</para>
/// <para><b>usable = max_connections − superuser_reserved_connections − reserved_connections</b>, engine-defined:
/// PostgreSQL refuses the connection that would take the last <c>superuser_reserved_connections</c> slots
/// unless the role is a superuser, so an ordinary application sees the cliff there, not at
/// <c>max_connections</c>. PostgreSQL 16 added <c>reserved_connections</c> (slots held for members of
/// <c>pg_use_reserved_connections</c>, 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).</para>
///
/// <para><b>Precedence: redaction first.</b> 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
Expand Down Expand Up @@ -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(
Expand All @@ -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;

Expand All @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] =
Expand Down Expand Up @@ -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"),
];
Expand Down
13 changes: 12 additions & 1 deletion PerformanceMonitor.Analysis/ComparisonBanding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,18 @@ public static class ComparisonBanding
public const string PresenceComparisonOnly = "comparison_only";
public const string PresenceBaselineOnly = "baseline_only";

/// <summary>The <c>BAD_ACTOR_&lt;query_hash&gt;</c> family — per-query identity, reported as churn.</summary>
/// <summary>The <c>BAD_ACTOR_&lt;query_hash&gt;</c> family — per-query identity, reported as churn.
///
/// <para><b>Deliberately NOT <c>PG_BAD_ACTOR_</c> (#3542, decided between waves).</b> The PostgreSQL twin is keyed
/// on <c>pg_stat_statements.queryid</c>, which is stable for the life of a PostgreSQL major (it changes only
/// across a major upgrade or a <c>compute_query_id</c> change — the advice says so), whereas SQL Server's
/// <c>query_hash</c> 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
/// <c>PG_BAD_ACTOR_*</c> key therefore falls through <see cref="IsPlanCacheIdentityKey"/> (no shared prefix)
/// and takes the presence path.</para></summary>
public const string PlanCacheIdentityPrefix = "BAD_ACTOR_";

/// <summary>
Expand Down
Loading
Loading