Repository navigation
Add get_read_latency: p50/p95/p99 durations per web and MCP read, and name the web and MCP store connections (#4442) - #4454
Merged
Conversation
… name the web and MCP store connections Adds get_read_latency on web and MCP, reading collect.read_latency's hourly histograms: per (surface, route), summed run_count/total_ms, MAX max_ms, element-wise summed bucket_counts, and the timeout run count, with p50/p95/p99 computed from the summed histogram as bucket upper-bound estimates. Sorted p95 desc. Also sets ApplicationName (PerformanceMonitorDarling-Web / PerformanceMonitorDarling-Mcp) on the web and MCP store connections, both the managed-mode role connection strings and the compose-store role connection strings, so each surface's pool names itself in the store's pg_stat_activity. Refs #4442
…dow, ApplicationName Adds GetReadLatencyLiveTests: a two-hour field fixture with hand-computed p50/p95/p99, count, mean, max and timeouts for a fast route and a route with a slow tail plus two timeout rows, asserted through the tool's own call path so the sort order (worst p95 first) is pinned too; surface and route filter pins; the honest-empty-window pin; and an ApplicationName pin that opens the web and MCP store connections through the product's own DarlingStoreLogins.BuildComposeStoreRoleConnectionString builder and checks pg_stat_activity. Fixes two real bugs the fixture surfaced in DarlingReadLatencyReader: a route with zero timeout rows returned NULL instead of 0 for timeouts (a FILTERed sum with no matching rows is NULL, not 0), and the bucket sum's result was numeric (Postgres sum() over bigint), which failed to cast to long[] on every read with more than one flushed row. Both are now explicit casts/coalesces in the read SQL. Refs #4442
erikdarlingdata
marked this pull request as ready for review
September 27, 2026 00:23
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #4442 (scope 2).
Why
The service now records every web read's and composed-panel read's duration into hourly latency
histograms (
collect.read_latency), but nothing reads that table back yet. This adds the read thatanswers "what's really slow": p50/p95/p99, count, mean, max and timeout counts per (surface, route),
so a regression shows on a dashboard instead of a guess from the last slow-query log line. It also
names the web dashboard's and MCP server's own store connections in
pg_stat_activity, which todayshow up as an unlabeled
viewer/mcpbackend indistinguishable from any other session under that role.What changes
DarlingReadLatencyReader(new): readscollect.read_latencyfor a window, summingrun_count,total_ms, and the timeout count straight off the table, and separately explodingbucket_countswith
unnest(...) WITH ORDINALITYto sum it ELEMENT-WISE per (surface, route) before re-collecting itwith
array_agg(... ORDER BY ord). Two joined CTEs rather than one pass, because summingrun_countoff the exploded rows would multiply every row by its own bucket-array length.
DarlingMcpReadLatencyTools.GetReadLatency(new MCP toolget_read_latency):hours(default 24,validated by the shared
ValidateHoursBack), optionalsurface/routefilters,limit(default 25,validated by
ValidateTop). Computes p50/p95/p99 in C# from the summed bucket histogram via theexisting
ReadLatencyPercentiles.FromBuckets, sorts by p95 descending then count, and returns the houseJSON envelope (mirrors
get_collector_cost's shape) with a top-level note that every percentile is abucket UPPER-BOUND estimate, never an interpolation. An empty window returns an honest empty status
naming the reason (pre-V148 store, or a genuinely quiet window).
CatalogDescriptorsentry and aBuildReadDispatchrow forget_read_latency,same params, so catalog/dispatch key parity holds.
ApplicationNameon the web and MCP store connections:DarlingManagedPostgres.BuildRoleConnectionStringnow takes an optional
applicationName, set to the newWebApplicationName/McpApplicationNameconstants(
PerformanceMonitorDarling-Web/PerformanceMonitorDarling-Mcp) for the managed-mode viewer/mcp roleconnection strings.
DarlingStoreLogins.BuildComposeStoreRoleConnectionStringsets the same names on thecompose-store role path (bring-your-own and container-provisioned stores). The service's own collection
connections to a monitored server are untouched.
/coremembershipNot added to
DarlingCoreToolProfile's closure: it is not one of the four entry tools and nonext_toolstable names it, so it follows the profile's own computed-closure rule rather than a manualadd. It is reachable on the default
/path.Test plan
Build:
Darling.Tests.csprojandLite.Tests.csprojboth build withEnableWindowsTargeting=true,0 warnings-as-relevant / 0 errors on both.
Ran in-process on this machine (
Darling.Tests.dll,-class):DarlingCoreToolProfileTests:Total: 8, Failed: 0— the closure count and membership are unaffectedby the new tool, confirming it correctly sits outside
/core.DarlingCustomViewsTests:Total: 71, Failed: 0— includesCatalogDescriptors_Keys_EqualTheReadDispatchKeysandCatalog_Viz_IsExactlyKnownViz(theBuildReadDispatch().Countvs the emitted catalog'sreadsarray length), both computed rather than ahardcoded total, so they pass with the new entry present on both sides.
DocCommentHygieneTests:Total: 77, Failed: 0.ReadmeDerivedCountPinTests:Total: 5, Failed: 0— unaffected; this tool isn't one of the countedclaims in
Darling/README.md.Lite/Darling MCP-tool census (
Lite.Tests/CrossAppMcpToolInventoryPinTests.cs): addedget_read_latencytoKnownLiteMissingMcpToolswith the same architectural-boundary rationale asget_collector_costand its neighbors (an internal self-metric over the central store's read pipeline,which Lite — no central store, no
/api/read/*dispatch loop of this shape — has no twin for). Not runhere (it's a Lite.Tests-only class, cross-referencing source text, not requiring the live rig); the build
passing is the compile-time check, and CI runs the assertion.
Live pins added (
GetReadLatencyLiveTests, new file)Run on a scratch Postgres container (macOS in-process
Darling.Tests.dll):Total: 4, Failed: 0.TwoRoutesAcrossTwoHours_MatchHandComputedPercentilesCountsAndSortOrder): seedscollect.read_latencyacross two hours throughReadLatencyAccumulator, then reads it back throughDarlingMcpReadLatencyTools.GetReadLatency(the tool's own call path, not the reader directly):get_wait_stats, web, fast): 100 rows, 50 at 50 ms and 50 at 90 ms. n=100, total=7,000,mean=70, max=90, 0 timeouts. Hand-computed: p50=50 ms (bucket cumulative reaches the p50 target at
the 50 ms bound), p95=100 ms, p99=100 ms.
get_plan_cache, web, a slow tail): 94 rows at 150 ms, 5 rows at 2,000 ms (~5% at 1-5 s),1 row at 30,000 ms (~1% at 20-60 s), plus 2 rows recorded with outcome Timeout at 61,000 ms each
(the accumulator's exact lowercase
"timeout"spelling). n=102, total=176,100, mean=1,726(integer division), max=61,000, timeouts=2. Hand-computed: p50=150 ms, p95=2,000 ms, p99=90,000 ms
(the first bound at/above the cumulative 99th-percentile row, which falls in the same bucket as the
two Timeout rows).
first" order.
SurfaceAndRouteFilters_EachScopeToOneRow): onesurface-filtered call returns exactlythe
compose/custom-viewrow, oneroute-filtered call returns exactly theweb/get_wait_statsrow, from a fixture with both present.
AnEmptyWindow_GivesTheDocumentedHonestEmptyResult): a freshly migrated store withno rows returns
{"status":"empty", ...}with the documented message, not an error.ApplicationName_ThroughTheProductsOwnConnectionBuilder_NamesEachSurfacesBackend):creates
viewer/mcplogins on the scratch store, builds their connection strings through theproduct's own
DarlingStoreLogins.BuildComposeStoreRoleConnectionString(no hand-written connectionstring), opens each, and asserts
SELECT application_name FROM pg_stat_activity WHERE pid = pg_backend_pid()returnsPerformanceMonitorDarling-Webfor the viewer login andPerformanceMonitorDarling-Mcpfor the mcp login.Two real bugs the fixture found and fixed in
DarlingReadLatencyReaderBuilding the fixture surfaced two bugs in the read SQL, both now fixed in this PR:
sum(run_count) FILTER (WHERE outcome = 'timeout')returns NULL when no row in the group matches the filter (proven againstPostgres directly:
SELECT sum(x) FILTER (WHERE false) FROM (VALUES(1),(2)) t(x)returns NULL, not0), and the reader read that column with
GetInt64, which throws on NULL. Fixed withcoalesce(..., 0).numeric[], which failed to cast tolong[]. Postgres'ssum()over abigintcolumn returnsnumeric, soarray_agg(sum(b.bucket_count) ...)produced anumeric[], and(long[])reader.GetValue(5)threwInvalidCastException: Unable to cast object of type 'System.Decimal[]' to type 'System.Int64[]'on every window with more than one flushed hour.Fixed with an explicit
::bigintcast on the summed value before it goes intoarray_agg.Both would have hit any real store with more than a trivial amount of read-latency history; the field
fixture (two hours, two routes) was enough to trip both.
Mutation: the element-wise bucket sum
Changed
sum(b.bucket_count)tomin(b.bucket_count)(uncommitted, in the working tree) in thebucketsCTE:TwoRoutesAcrossTwoHours_MatchHandComputedPercentilesCountsAndSortOrderwent RED(percentile/count assertions failed against the mutated histogram). Reverted; the class is GREEN again
at the head of this PR. (An earlier attempt with
max(...)did not go red for this fixture's shape —minis the mutation that actually exercises the element-wise-sum claim end to end.)Full pin run (this machine, in-process, against a scratch Postgres 18 + TimescaleDB container)
GetReadLatencyLiveTests:Total: 4, Failed: 0DarlingCoreToolProfileTests:Total: 8, Failed: 0DarlingCustomViewsTests:Total: 71, Failed: 0ReadmeDerivedCountPinTests:Total: 5, Failed: 0DocCommentHygieneTests:Total: 77, Failed: 0Darling.Tests.csprojandLite.Tests.csprojboth build withEnableWindowsTargeting=true: 0warnings-as-relevant / 0 errors on both.
The RED against dev's pre-fix code is compile-only (
GetReadLatencyLiveTestscalls a tool that does notexist there); the mutation above is the runtime proof that the live pin actually exercises the summed
histogram, not just that the tool compiles and runs.
CHANGELOG entry
SECTION: Added
ENTRY:
REF:
[Add get_read_latency: p50/p95/p99 durations per web and MCP read, and name the web and MCP store connections (#4442) #4454]: Add get_read_latency: p50/p95/p99 durations per web and MCP read, and name the web and MCP store connections (#4442) #4454