Repository navigation
Add Postgres/Aurora instance CPU collector via Performance Insights, wire High CPU alert - #2727
Conversation
…wire High CPU alert (#2719) PostgreSQL has no instance-level CPU signal anywhere in this codebase: pg_stat_kcache measures per-query kernel time, not "is this instance saturated right now", and core Postgres exposes nothing else. pg_cpu_utilization reads AWS RDS Performance Insights' os.cpuUtilization.total.avg over the RDS/PI API (RdsCpuIngestor), the same "reach the target a different way" shape #2538 established for plan capture and deadlocks - chosen over CloudWatch's AWS/RDS/CPUUtilization after live testing showed CloudWatch reading capacity-relative and roomy on Serverless v2 (~6.8%) against PI's true OS-level utilization on the identical window (~16.8%); PI is universally enabled across the fleet already. Aurora/RDS only - self-hosted Postgres has no equivalent route, so the collector no-ops there (RdsEndpoint.TryParse returns null) rather than fake a signal. EvaluatePgCpuAsync wires a High CPU/CPU Resolved alert pair into EvaluatePostgresAlertsAsync, reusing SQL Server's own CpuEnabled/CpuThresholdPercent settings and exact metric-name strings for parity, mirroring EvaluatePgLongRunningQueryAsync's active-flag+cooldown shape. Migration V106 verified column-for-column against PgSchemaGenerator's output and against a real local PostgreSQL 17 (DDL + the alert's read query). CI's throwaway cluster worker-sizing bumped 71/82 -> 72/83 for the new hypertable (CiClusterWorkerSizingTests). Fixes #2719 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ddt5CYTp8CD2wreF6sunxj
|
Reviewed the new Postgres/Aurora CPU collector + High CPU alert (#2719). Overall this is well-structured and follows the codebase's established patterns closely (PERMISSIONS degradation mirroring #2633, watermark-from-store reasoning, dispatch-table shape, parameterized SQL throughout — no injection concerns). Lite has no Postgres collectors at all today, so there's no Lite/Darling parity gap here; this is correctly Darling-only. Two concrete issues below as inline comments:
Nothing else stood out as a correctness, security, or Lite/Darling-parity problem. |
| CREATE INDEX IF NOT EXISTS idx_pg_cpu_utilization_time | ||
| ON collect.pg_cpu_utilization(server_id, collection_time);"; |
There was a problem hiding this comment.
The only index here is (server_id, collection_time), but neither hot-path query for this table filters/sorts by collection_time — both key off sample_time:
RdsCpuIngestor.GetWatermarkAsync:SELECT MAX(sample_time) FROM collect.pg_cpu_utilization WHERE server_id = $1— once per ingest cycle (5 min/server)DarlingPgCpuUtilizationReader.LatestCpuSql:... WHERE server_id = $1 AND sample_time >= $2 ... ORDER BY sample_time DESC LIMIT 1— once per alert sweep, i.e. every 30s per server (s_alertSweepInterval)
Without sample_time in an index, Postgres can only use (server_id, collection_time) to narrow by server_id, then has to filter/sort the full matching row set (up to the full 30-day retention window per server, ~8,640 rows at the 5-min cadence) on every single sweep instead of an efficient index scan/limit.
Worth adding (or swapping to) an index on (server_id, sample_time DESC) so both the MAX() watermark read and the ORDER BY sample_time DESC LIMIT 1 alert read can use it directly. The ingestor's own doc comment ("costs one indexed query per cycle" — RdsCpuIngestor.cs:40) assumes this index exists, which it doesn't.
| /// utilization on the identical window (~16.8%). PI is the honest signal for this fleet, and it is | ||
| /// universally available — every monitored Aurora Postgres instance already has it enabled.</para> | ||
| /// </summary> | ||
| public sealed class PgCpuUtilizationCollector : PostgresCollectorDefinitionBase<PgCpuUtilizationCollector.Row> |
There was a problem hiding this comment.
This collector doesn't override AppliesTo, so it inherits the base's => true and applies to every Postgres target, including self-hosted. But per this class's own doc comment, self-hosted has no possible route (unlike pg_deadlocks/pg_plan_capture, which fall back to a real pg_read_file-based SQL query for self-hosted — see the dispatch table comment in DarlingWorker.cs). The dispatch entry for pg_cpu_utilization is unconditional and always calls IngestPgCpuAsync, which for a self-hosted host resolves nothing in RdsEndpoint.TryParse and silently no-ops.
Net effect: every self-hosted Postgres target gets this collector scheduled and dispatched every 5 minutes forever, permanently logging a SUCCESS/0-row "no new Performance Insights CPU samples this cycle" collection_log entry for a target class that can structurally never produce one.
There's already a direct precedent for gating exactly this shape — PgWaitStatsCollector.AppliesTo => target.IsAurora (Aurora-only, no self-hosted equivalent). This collector should probably do the same (target.IsAurora || target.IsAwsRds), which would also let the dispatch-table entry drop its explanatory no-op comment since the scheduler would simply never invoke it for a target where it can't do anything.
…r parity The V106 migration script and Scripts registration were already correct; StorageVersion.SchemaVersion just never got bumped to 106 and committed, which is what every "Expected: 105, Actual: 106" dense-ladder test failure traced back to. Separately, the V106 doc comment had been spliced between V103's own doc comment and its const, stranding two summary blocks together above V106Sql and orphaning V103's comment above the wrong member (DocCommentHygieneTests.NoMemberCarriesTwoStackedSummaryBlocks). Moved V106's doc+const block to sit after V103Sql's own declaration instead. ViewerPostgresTabsTests.EveryPostgresCollector_IsShownOnExactlyOnePostgresTab required a real Overview-tab panel for pg_cpu_utilization, not just a registry entry - added the WPF grid, its note, and the ViewerDataService projection. The MCP tool, web dashboard registration, and Aurora-only collector gate (mirroring PgWaitStatsCollector) round out parity across all three surfaces. Fixes #2719.
| [McpServerToolType] | ||
| public sealed class DarlingMcpPgCpuUtilizationTools | ||
| { | ||
| [McpServerTool(Name = "get_pg_cpu_utilization"), Description("Gets instance-level CPU utilization over time for a PostgreSQL/Aurora target, from AWS Performance Insights' os.cpuUtilization.total.avg. Data is downsampled to 1-minute averages. Aurora and RDS only - self-hosted PostgreSQL has no instance-level CPU source and this returns empty for one.")] |
There was a problem hiding this comment.
Minor doc/behavior mismatch: this tool description (and the matching DarlingWebEndpoints.cs:1140 catalog description, and the CHANGELOG entry) say "Aurora and RDS only", but PgCpuUtilizationCollector.AppliesTo gates on target.IsAurora alone (PerformanceMonitor.Collectors/PgCpuUtilizationCollector.cs:61). A plain (non-Aurora) RDS Postgres target is filtered out entirely upstream in DarlingWorker's CollectorCatalog.AppliesTo(name, target) pre-dispatch gate, so pg_cpu_utilization never runs for it — even though RdsCpuIngestor.IngestAsync itself would happily handle that host via RdsEndpoint.TryParse. The collector's own doc comment acknowledges this ("nothing in the fleet is that shape" today), but the three user/LLM-facing description strings advertise plain-RDS support the code doesn't currently deliver. Worth tightening the wording to "Aurora only" (or wiring the gate to also allow non-Aurora RDS) so an operator/LLM client isn't misled if a non-Aurora RDS Postgres server is ever added to the fleet.
| pg_index_bloat (#2561) and pg_wait_sampling, pg_kernel_stats, pg_predicate_stats (#2603) and pg_plan_capture (#2566) = 66, plus | ||
| pg_cpu_utilization (#2719, instance CPU via the RDS/Performance Insights API — no SQL route at | ||
| all, see its own doc comment) = 69. The catalog is |
There was a problem hiding this comment.
The running tally in this comment doesn't add up: "...= 66, plus pg_cpu_utilization (...) = 69" adds one collector but jumps the total by 3. The Assert.Equal(69, ...) below is correct (matches CollectorCatalog.All.Count), but the count was already drifted (68 actual vs. "= 66" in the old comment, a pre-existing gap this PR didn't introduce) before this edit. Since this line was touched anyway, it'd be worth fixing the arithmetic so the tally is self-consistent again — CONTRIBUTING.md calls out that these comments are relied on ("a comment restating the code is noise... this stops the next person undoing it"), and a visibly-wrong running sum undermines that for the next contributor who adds a collector here.
|
Reviewed the diff (RDS/Aurora Postgres CPU collector via Performance Insights, High CPU alert wiring, viewer/MCP/web-dashboard surfaces, V106 migration, CI worker-sizing bump). Overall: solid, well-precedented change. Lite/Darling parity: no concern here — PostgreSQL/Aurora monitoring (and all RDS-API-based ingestors: plan capture, deadlocks, and now CPU) is Darling-only today; Lite has no Postgres collector dispatch path at all, so there's nothing for this PR to keep in parity with on the Lite side. Left two minor inline notes:
Neither is a functional bug; both are doc/comment accuracy nits. |
…ng, census pins DarlingMcpPgCpuUtilizationTools was declared but never registered with the host (.WithGeminiCompatibleTools<T>()), so the tool it declares was unreachable over MCP despite existing everywhere else. ViewerSchemaVersionGateTests: RequiredStoreSchemaVersion's own probe/map pair is separate from the dense-ladder pins already fixed - added the V106 sentinel (pg_cpu_utilization table existence) and its top arm. pg_cpu_utilization had no CapturePathByCollector entry, so its not-collected message would have fallen back to generic phrasing instead of naming the actual source. Added get_pg_cpu_utilization to Lite's ratchet allow-list (Darling-only: Lite has no PostgreSQL target and no AWS credentials of its own to reach Performance Insights regardless), and bumped the Darling MCP instructions' tool census (135->136 total, 31->32 PostgreSQL reads) to match. Fixes #2719.
| /// the engine-capability sweep (<c>CollectorEngineCapability.TargetsWithEngineKind</c>) already varies | ||
| /// correctly for PostgreSQL — unlike <c>CollectorTargetInfo.IsAwsRds</c>, which is documented as a | ||
| /// SQL-Server-only fact and is never varied for a Postgres target.</summary> | ||
| public override bool AppliesTo(CollectorTargetInfo target) => target.IsAurora; |
There was a problem hiding this comment.
Correctness: AppliesTo => target.IsAurora silently excludes plain (non-Aurora) RDS Postgres targets from ever running this collector — contradicting this file's own doc comment and the PR's stated "Aurora/RDS only" scope.
RunDueCollectorsAsync in DarlingWorker.cs gates dispatch with:
if (!CollectorCatalog.AppliesTo(name, runtime.Target)) continue;and CollectorCatalog.AppliesTo(name, target) = EngineMatches(...) && definition.AppliesTo(target) (CollectorCatalog.cs:140-141). Because this collector's AppliesTo returns target.IsAurora, a plain RDS-Postgres target (not Aurora) never reaches the ["pg_cpu_utilization"] = (r, s, ct) => r.IngestPgCpuAsync(s, ct) dispatch lambda at all — the loop continues before IngestPgCpuAsync/RdsCpuIngestor.IngestAsync is ever invoked.
This directly contradicts:
- This method's own doc comment: "A plain (non-Aurora) RDS Postgres instance would still be tried at runtime by
RdsCpuIngestor(it dispatches onRdsEndpoint.TryParsesucceeding, not on this flag)" — it is never reached, so it is never tried. - The dispatch-table comment in
DarlingWorker.cs(["pg_cpu_utilization"]entry): "A self-hosted host resolves to nothing inIngestPgCpuAsync'sRdsEndpoint.TryParseand the ingestor no-ops" — same problem, self-hosted also failsIsAuroraand never reaches the ingestor to no-op. RdsCpuIngestorTests.ReaderAndCustomEndpointsAreRefused/ANonRdsHostReturnsZero, which unit-testIngestAsyncdirectly but don't reflect a path the production scheduler will ever actually take for a non-Aurora host.
This differs from the precedent it says it follows (PgWaitStatsCollector.AppliesTo => target.IsAurora), where gating on Aurora is correct because aurora_stat_system_waits() genuinely doesn't exist outside Aurora. AWS Performance Insights, by contrast, is generally available on standalone RDS Postgres instances too — not Aurora-specific — so gating it away here is a real capability gap, not a faithful mirror of the wait-stats precedent.
Compare with PgPlanCaptureCollector.AppliesTo => true (PgPlanCaptureCollector.cs:136), which defers the Aurora/RDS-vs-self-hosted routing decision entirely to the dispatch lambda (s.Target.IsAurora || s.Target.IsAwsRds ? r.IngestRdsPlansAsync(...) : ...). That's the shape this collector needs too: AppliesTo should return true (letting IngestPgCpuAsync/RdsCpuIngestor.IngestAsync's own RdsEndpoint.TryParse decide per-target at runtime, as the comments already claim it does), not target.IsAurora.
Concrete impact: if a plain RDS Postgres server is ever added to the fleet (the PR body says "nothing in the fleet is that shape" today, so this is currently dormant), it will silently never collect pg_cpu_utilization or fire the High CPU alert for that server — no error, no PERMISSIONS row, nothing; just a collector that never gets scheduled.
Review summaryReviewed the Postgres/Aurora CPU-via-Performance-Insights collector and High CPU alert wiring. Correctness (posted inline): Lite/Darling parity: No drift — Lite has no PostgreSQL support at all, and the new Security: No issues found. All new SQL is parameterized ( Style/other: T-SQL conventions don't really apply here (this collector has no SQL route by design — No other correctness, performance, or security issues found. |
Both lanes appended a PG alert sibling at the same call site and a CHANGELOG entry at the same anchor - kept both: the sweep now runs Poison Wait then High CPU, and both Unreleased entries and issue links survive. Resolved CHANGELOG on bytes, CRLF preserved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLaaxFenZFZs1SntKQDEg
Fixes #2719.
Problem
PostgreSQL has no instance-level CPU signal anywhere in this codebase.
pg_stat_kcachemeasures per-QUERY kernel time, not "is this instance CPU-saturated right now", and core PostgreSQL exposes nothing else. The fleet overview shows no CPU number for any pgmon server today, and #2711's own table incorrectly classified this as a "light design" alert-wiring task — it's actually a missing-data-source problem.Decision: Performance Insights over CloudWatch
Live-tested both
AWS/RDS/CPUUtilization(CloudWatch) andos.cpuUtilization.total.avg(Performance Insights) against the same instance/window: CloudWatch read ~6.8% (capacity-relative, roomy on Serverless v2), PI read ~16.8% (true OS-level utilization). PI is the honest signal for this fleet, and all 153/153 monitored Aurora Postgres instances already have it enabled — no enablement gap.What's here
PgCpuUtilizationCollector(PerformanceMonitor.Collectors) — the schema/column definition.BuildQuery/ReadAsyncare unreachable by design (throwNotSupportedException): this collector has no SQL route at all, ever — see its doc comment.RdsCpuIngestor(Darling.Service.Targets) — reads PI viaAWSSDK.PI, mirroringRdsDeadlockIngestor/RdsPlanIngestor's "reach the target through the AWS API" shape from PostgreSQL has no execution plan capture, which is where a DBM comparison is lost #2538. Resolves the Aurora cluster writer and the instance'sDbiResourceId, reads its own resume watermark from the store (not in-memory — see its doc comment for why), and writes via the same binary-COPY path every collector uses.PiMetricsUnavailableException— a distinct type fromRdsLogUnavailableExceptionso the PERMISSIONS-degradation handler inDarlingWorkernames the correct IAM grants (pi:GetResourceMetrics,rds:DescribeDBInstances,rds:DescribeDBClusters) rather than the log-capture ones.EvaluatePgCpuAsync(DarlingWorker) — High CPU / CPU Resolved alert pair, wired intoEvaluatePostgresAlertsAsync. Reuses SQL Server's ownCpuEnabled/CpuThresholdPercentsettings and exact metric-name strings ("High CPU","CPU Resolved") for Datadog/mute-rule/AlertMetricClassifierparity — one threshold means the same thing on both engines. MirrorsEvaluatePgLongRunningQueryAsync's active-flag+cooldown shape (a continuous gauge, notRollingCountAlertGate's rolling-window-count shape).DarlingPgCpuUtilizationReader(Darling.Storage) — the alert's read side, latest non-null reading within a 15-minute freshness bound.pg-cpu-utilization), column-for-column verified againstPgSchemaGenerator's output (PgSchemaGeneratorTests.EveryPostgresRung_IsIdenticalToTheGeneratedSchema).build.yml/nightly.ymlfor the new hypertable (CiClusterWorkerSizingTestsderives these from the live collector count).Aurora/RDS only. Self-hosted PostgreSQL has no equivalent CPU route, so
RdsCpuIngestorno-ops silently for a non-RDS host (RdsEndpoint.TryParsereturns null) — the same answerRdsLogSourcegives for the same case — rather than fake a signal.Verified
dotnet build PerformanceMonitor.sln).LatestCpuSqlread query, both run against a real local PostgreSQL 17 (throwaway docker container): confirmed the freshest non-null, non-stale reading wins, a stale row is excluded, a null-cpu_percentrow is excluded even when it's the freshest sample, and a different server's row never leaks in.AWSSDK.PI's actual API surface (types, method signatures,ServiceType.RDS) verified via reflection against the restored package before writingRdsCpuIngestor, rather than guessed from memory.Test plan
dotnet buildclean, 0 errorsDarlingPgCpuUtilizationReaderTests(structural SQL-text pins, matchingDarlingPgSessionStatesReaderTests' own pattern)RdsCpuIngestorTests(the two decision points reachable without a store connection — non-RDS host no-op, reader/custom endpoint refusal — matchingRdsLogSourceTests' scope for the same shared decisions)PgSchemaGeneratorTests/CollectorCatalog/CollectorScheduleDefaults/DarlingWorkerTestsdispatch-completeness all updated for the new collectorKnown gap, not fixed here
Like
EvaluatePgLongRunningQueryAsync(added after #2716 shipped), this alert's cooldown/active-flag watermarks are in-memory only and do not survive a service restart. This matches existing shipped precedent, not a regression — filing separately if it's worth fixing across both alerts uniformly.Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01Ddt5CYTp8CD2wreF6sunxj