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
5 changes: 2 additions & 3 deletions Darling/Darling.Tests/AnalysisPassTokenThreadingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ public sealed class AnalysisPassTokenThreadingTests
/// </summary>
private static readonly Dictionary<string, string> s_exempt = new(StringComparer.Ordinal)
{
["GetRecentFindingsAsync"] = "read-back: the MCP + viewer findings read, no pass to abandon",
["GetLatestFindingsAsync"] = "read-back: the viewer's Recommendations tab, no pass to abandon",
["GetMutedStoriesAsync"] = "read-back: the viewer's mute registry read, no pass to abandon",
["MuteStoryAsync"] = "off-pass write: the MCP/viewer mute verb, no pass to abandon",
Expand Down Expand Up @@ -173,8 +172,8 @@ public void EveryExemptionIsStillEarningItsPlace()
Assert.Equal(s_exempt.Keys.OrderBy(k => k, StringComparer.Ordinal), untokenedBy.Keys.OrderBy(k => k, StringComparer.Ordinal));

/* The counts are stated so a NEW untokened call inside an already-exempt method cannot ride in
on the exemption. 15 read-back calls across six methods, and the one finding INSERT. */
Assert.Equal(16, untokenedBy.Values.Sum());
on the exemption. 12 read-back calls across five methods, and the one finding INSERT. */
Assert.Equal(13, untokenedBy.Values.Sum());
Assert.Equal(1, untokenedBy["InsertFindingAsync"]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ letting the halves drift. */
/// gets <see cref="PgAnomalyDetector"/>'s, off the one resolution this read already performs.</para>
/// </summary>
public async Task<(List<Fact> Facts, WindowCoverage? Coverage, CollectionCaveatState Caveats)> CollectAndScoreFactsAsync(
int serverId, string serverName, int hoursBack = 4, DateTime? asOfUtc = null)
int serverId, string serverName, int hoursBack = 4, DateTime? asOfUtc = null, CancellationToken cancellationToken = default)
{
var timeRangeEnd = asOfUtc ?? DateTime.UtcNow;
var timeRangeStart = timeRangeEnd.AddHours(-hoursBack);
Expand All @@ -729,7 +729,8 @@ letting the halves drift. */
ServerName = serverName,
TimeRangeStart = timeRangeStart,
TimeRangeEnd = timeRangeEnd,
AsOfUtc = asOfUtc
AsOfUtc = asOfUtc,
CancellationToken = cancellationToken
};

try
Expand Down Expand Up @@ -835,22 +836,25 @@ context.CancellationToken is the caller's own token. */
public async Task<(List<Fact> BaselineFacts, List<Fact> ComparisonFacts, WindowCoverage? BaselineCoverage, WindowCoverage? ComparisonCoverage, IReadOnlyDictionary<string, BaselineBucket> Dispersion)> ComparePeriodsAsync(
int serverId, string serverName,
DateTime baselineStart, DateTime baselineEnd,
DateTime comparisonStart, DateTime comparisonEnd)
DateTime comparisonStart, DateTime comparisonEnd,
CancellationToken cancellationToken = default)
{
var baselineContext = new AnalysisContext
{
ServerId = serverId,
ServerName = serverName,
TimeRangeStart = baselineStart,
TimeRangeEnd = baselineEnd
TimeRangeEnd = baselineEnd,
CancellationToken = cancellationToken
};

var comparisonContext = new AnalysisContext
{
ServerId = serverId,
ServerName = serverName,
TimeRangeStart = comparisonStart,
TimeRangeEnd = comparisonEnd
TimeRangeEnd = comparisonEnd,
CancellationToken = cancellationToken
};

try
Expand Down Expand Up @@ -916,9 +920,9 @@ public async Task<List<AnalysisFinding>> GetLatestFindingsAsync(int serverId)
/// the default 24-hour window has scrolled past.</para>
/// </summary>
public async Task<List<AnalysisFinding>> GetRecentFindingsAsync(
int serverId, int hoursBack = 24, int limit = 100, DateTime? asOfUtc = null)
int serverId, int hoursBack = 24, int limit = 100, DateTime? asOfUtc = null, CancellationToken cancellationToken = default)
{
return await _findingStore.GetRecentFindingsAsync(serverId, hoursBack, limit, asOfUtc);
return await _findingStore.GetRecentFindingsAsync(serverId, hoursBack, limit, asOfUtc, cancellationToken);
}

/// <summary>
Expand Down
15 changes: 5 additions & 10 deletions Darling/PerformanceMonitor.Darling.Analysis/PgFindingStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -609,14 +609,9 @@ public async Task<PriorOccurrenceRead> GetPriorOccurrencesAsync(AnalysisContext
/// <summary>
/// Returns the most recent findings for a server within the given time range, newest and
/// most severe first, including each finding's persisted remediation action.
///
/// <para>#2443 exempt: off the analysis pass. This surface serves the viewer, the MCP and the
/// retention sweep — lifetimes with no per-pass budget and no wedged analysis to abandon — so
/// its store calls take no pass token. Threading one here would mean inventing a caller that
/// does not exist.</para>
/// </summary>
public async Task<List<AnalysisFinding>> GetRecentFindingsAsync(
int serverId, int hoursBack = 24, int limit = 100, DateTime? asOfUtc = null)
int serverId, int hoursBack = 24, int limit = 100, DateTime? asOfUtc = null, CancellationToken cancellationToken = default)
{
var findings = new List<AnalysisFinding>();

Expand All @@ -628,20 +623,20 @@ public async Task<List<AnalysisFinding>> GetRecentFindingsAsync(
zone-shifted. */
var windowEnd = DateTime.SpecifyKind(asOfUtc ?? DateTime.UtcNow, DateTimeKind.Unspecified);

await using var connection = await _postgres.OpenConnectionAsync();
await using var connection = await _postgres.OpenConnectionAsync(cancellationToken);
using var command = new NpgsqlCommand(GetRecentFindingsSql, connection) { CommandTimeout = DarlingAnalysisService.AnalysisCommandTimeoutSeconds };
command.Parameters.AddWithValue(serverId);
command.Parameters.AddWithValue(windowEnd.AddHours(-hoursBack));
command.Parameters.AddWithValue(asOfUtc is null ? NoUpperBound : windowEnd);
command.Parameters.AddWithValue(limit);

using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
findings.Add(ReadFinding(reader));
}
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger?.LogError("[PgFindingStore] GetRecentFindingsAsync failed: {Message}", ex.Message);
}
Expand Down
15 changes: 5 additions & 10 deletions Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,6 @@ public static class DarlingWebEndpoints
/// </summary>
internal static readonly IReadOnlySet<string> CancellationAllowlist = new HashSet<string>(StringComparer.Ordinal)
{
"compare_analysis",
"get_analysis_facts",
"get_analysis_findings",
"get_file_io_trend",
"get_perfmon_trend",
"get_pg_column_stats",
"get_pg_cpu_utilization",
"get_pg_index_bloat",
Expand Down Expand Up @@ -2974,13 +2969,13 @@ internal static IReadOnlyDictionary<string, ReadToolHandler> BuildReadDispatch(I
{
/* ── analysis reads (take the DarlingAnalysisService) ── */
["audit_config"] = (c, pg, an) => DarlingMcpTools.AuditConfig(an, pg, Server(c), c.RequestAborted),
["compare_analysis"] = (c, pg, an) => DarlingMcpTools.CompareAnalysis(an, pg, Server(c), Hours(c, 4), QueryInt(c, "baseline_hours_back", null, 28), as_of: AsOf(c)),
["get_analysis_facts"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFacts(an, pg, Server(c), Hours(c, 4), Str(c, "source"), QueryDouble(c, "min_severity", 0), as_of: AsOf(c)),
["compare_analysis"] = (c, pg, an) => DarlingMcpTools.CompareAnalysis(an, pg, Server(c), Hours(c, 4), QueryInt(c, "baseline_hours_back", null, 28), as_of: AsOf(c), cancellationToken: c.RequestAborted),
["get_analysis_facts"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFacts(an, pg, Server(c), Hours(c, 4), Str(c, "source"), QueryDouble(c, "min_severity", 0), as_of: AsOf(c), cancellationToken: c.RequestAborted),
// #4198: the tool's own default (limit 18, previews) is sized for a chat caller's token budget.
// The viewer's two "Analysis Findings" tables render no preview-cut field and never asked for a
// budget, so this row keeps asking for what dev always returned: every chain, full text. See
// DarlingWebEndpointsTests.GetAnalysisFindingsRow_PassesTheOldViewerDefaults_EveryChainFullText.
["get_analysis_findings"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFindings(an, pg, Server(c), Hours(c, 24), Rows(c, "limit", MaxRowLimit), QueryBool(c, "include_drilldown", false), QueryBool(c, "full_text", true), as_of: AsOf(c), logger: logger),
["get_analysis_findings"] = (c, pg, an) => DarlingMcpTools.GetAnalysisFindings(an, pg, Server(c), Hours(c, 24), Rows(c, "limit", MaxRowLimit), QueryBool(c, "include_drilldown", false), QueryBool(c, "full_text", true), as_of: AsOf(c), logger: logger, cancellationToken: c.RequestAborted),

/* ── sessions ── */
["get_active_queries"] = (c, pg, an) => DarlingMcpSessionTools.GetActiveQueries(pg, Server(c), Hours(c, 1), Str(c, "database_name"), QueryBool(c, "blocking_only", false), Rows(c, "limit", 50), 2000, AsOf(c), c.RequestAborted),
Expand Down Expand Up @@ -3147,14 +3142,14 @@ cannot parse exactly rather than silently matching nothing. */
should read — and bind bucket_minutes, refusing a value that is not a number rather than quietly
sizing the points itself (the OptionalDouble rule). */
["get_file_io_trend"] = (c, pg, an) => OptionalInt(c, "bucket_minutes", out var bucketMinutes)
? DarlingMcpTrendTools.GetFileIoTrend(pg, Server(c), Hours(c, 24), AsOf(c), bucketMinutes, Str(c, "database_name"), TrendBudget.Chart)
? DarlingMcpTrendTools.GetFileIoTrend(pg, Server(c), Hours(c, 24), AsOf(c), bucketMinutes, Str(c, "database_name"), TrendBudget.Chart, c.RequestAborted)
: UnparseableParam("bucket_minutes"),
["get_memory_trend"] = (c, pg, an) => OptionalInt(c, "bucket_minutes", out var bucketMinutes)
? DarlingMcpTrendTools.GetMemoryTrend(pg, Server(c), Hours(c, 24), AsOf(c), bucketMinutes, TrendBudget.Chart, c.RequestAborted)
: UnparseableParam("bucket_minutes"),
["get_perfmon_trend"] = (c, pg, an) => RequireText(c, "counter_name", out var counter)
? (OptionalInt(c, "bucket_minutes", out var bucketMinutes)
? DarlingMcpTrendTools.GetPerfmonTrend(pg, counter, Server(c), Hours(c, 24), AsOf(c), bucketMinutes, TrendBudget.Chart)
? DarlingMcpTrendTools.GetPerfmonTrend(pg, counter, Server(c), Hours(c, 24), AsOf(c), bucketMinutes, TrendBudget.Chart, c.RequestAborted)
: UnparseableParam("bucket_minutes"))
: MissingParam("counter_name"),
["get_procedure_duration_trend"] = (c, pg, an) => OptionalInt(c, "bucket_minutes", out var bucketMinutes)
Expand Down
30 changes: 17 additions & 13 deletions Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,10 @@ public static async Task<string> GetAnalysisFacts(
[Description("Hours of data to analyze. Default 4.")] int hours_back = 4,
[Description(FactSourceFilterDescription)] string? source = null,
[Description("Minimum severity to include. Default 0 (all facts). Use 0.5 to see only significant facts.")] double min_severity = 0,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
[Description(McpHelpers.AsOfDescription)] string? as_of = null,
CancellationToken cancellationToken = default)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken);
if (error != null) return error;

var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd);
Expand All @@ -329,7 +330,7 @@ public static async Task<string> GetAnalysisFacts(
try
{
var (facts, coverage, collection) = await analysisService.CollectAndScoreFactsAsync(
resolved.ServerId, resolved.ServerName, hours_back, asOfUtc: anchor);
resolved.ServerId, resolved.ServerName, hours_back, asOfUtc: anchor, cancellationToken: cancellationToken);

/* #3691: null on a clean read, and then every envelope below is byte-for-byte what it was; when
a family failed, the sentence is appended to the message and collection_caveats to the payload. */
Expand Down Expand Up @@ -429,7 +430,7 @@ after configuration alone knows audit_config still has it. */
facts = result
}, McpHelpers.JsonOptions), McpHelpers.JsonOptions);
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
return McpHelpers.FormatError("get_analysis_facts", ex);
}
Expand All @@ -442,9 +443,10 @@ public static async Task<string> CompareAnalysis(
[Description("Server name or display name.")] string? server_name = null,
[Description("Hours back for the comparison (recent) period. Default 4.")] int hours_back = 4,
[Description("Hours back for the baseline period start, measured from the end of the comparison window (now, or as_of). Default 28 (yesterday same time).")] int baseline_hours_back = 28,
[Description(McpHelpers.AsOfDescription)] string? as_of = null)
[Description(McpHelpers.AsOfDescription)] string? as_of = null,
CancellationToken cancellationToken = default)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken);
if (error != null) return error;

var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd);
Expand All @@ -468,7 +470,8 @@ silently change what the two windows are relative to each other. */
var (baselineFacts, comparisonFacts, baselineCoverage, comparisonCoverage, dispersion) = await analysisService.ComparePeriodsAsync(
resolved.ServerId, resolved.ServerName,
baselineStart, baselineEnd,
comparisonStart, comparisonEnd);
comparisonStart, comparisonEnd,
cancellationToken);

/* The COLLECTION_GAP context fact (#3538 A2) is an observation of the COLLECTOR, not of the
server, and it is reported through the coverage blocks and caveat below. Left in the
Expand Down Expand Up @@ -592,7 +595,7 @@ description is not in front of the reader when the numbers are. */
facts = comparison.Rows.Select(r => r.ToPayload()).ToList()
}, McpHelpers.JsonOptions);
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
return McpHelpers.FormatError("compare_analysis", ex);
}
Expand Down Expand Up @@ -1085,9 +1088,10 @@ public static async Task<string> GetAnalysisFindings(
/* DI-resolved like postgres (no [Description]); trailing so the /api/read dispatch row and every
Darling.Tests call site, none of which name arguments past as_of, keep compiling. See
analyze_server's copy of this note for the full reasoning (#4316). */
ILogger? logger = null)
ILogger? logger = null,
CancellationToken cancellationToken = default)
{
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name);
var (resolved, error) = await DarlingServerResolver.ResolveOrErrorAsync(postgres, server_name, cancellationToken);
if (error != null) return error;

var validation = McpHelpers.ValidateWindow(hours_back, as_of, out var windowEnd);
Expand All @@ -1113,7 +1117,7 @@ exactly when the window held an occurrence the newest-first LIMIT dropped. This
against the store-read constant rather than a caller's limit: a window of exactly 10,000
occurrences read as cut. The page is the same newest 10,000 the store returned before. */
var fetched = await analysisService.GetRecentFindingsAsync(
resolved.ServerId, hours_back, FindingOccurrences.WindowCoveringLimit + 1, asOfUtc: anchor);
resolved.ServerId, hours_back, FindingOccurrences.WindowCoveringLimit + 1, asOfUtc: anchor, cancellationToken: cancellationToken);
var (findings, truncated) = McpHelpers.BoundPage(fetched, FindingOccurrences.WindowCoveringLimit);

if (findings.Count == 0)
Expand Down Expand Up @@ -1158,7 +1162,7 @@ than the fleet mean (~15/server/24h after collapse), and unlike the raw-read cap

/* #3652: one state read for every representative's force-plan targets — see analyze_server. */
var (forcePlanStates, forcePlanStateNote) =
await DarlingForcePlanTargetStateReader.TryReadAsync(postgres, resolved.ServerId, groups.Select(g => g.Latest), logger: logger);
await DarlingForcePlanTargetStateReader.TryReadAsync(postgres, resolved.ServerId, groups.Select(g => g.Latest), logger: logger, cancellationToken: cancellationToken);

return JsonSerializer.Serialize(new
{
Expand Down Expand Up @@ -1279,7 +1283,7 @@ than the fleet mean (~15/server/24h after collapse), and unlike the raw-read cap
})
}, McpHelpers.JsonOptions);
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
return McpHelpers.FormatError("get_analysis_findings", ex);
}
Expand Down
Loading
Loading