Skip to content

[BUG] - MCP Server get_query_trend silently truncates to raw-table retention window, ignoring requested hours_back #2353

Closed

Description

@carlei1978

Component

Darling

Performance Monitor Version

3.4.0

SQL Server Version

Microsoft SQL Server 2022 (RTM-CU16) (KB5048033) - 16.0.4165.4 (X64) Nov 6 2024 19:24:49 Copyright (C) 2022 Microsoft Corporation Standard Edition (64-bit) on Windows Server 2022 Datacenter 10.0 (Build 20348: ) (Hypervisor)

Windows Version

Windows Server 2025 Datacenter, 2009, 26100

Describe the Bug

The get_query_trend MCP tool accepts an hours_back parameter and echoes it back unchanged in its JSON response, but its underlying query only reads the raw query_stats  hypertable (DarlingTrendReader.QueryHistorySql), which is physically pruned by a hardcoded 4-day TimescaleDB retention policy (TimescaleSupport.RawRetentionInterval = "4 days") — independent of and much shorter than the collector's advertised 30-day query_stats retention (CollectorScheduleDefaults).

Unlike other Darling readers (e.g. ComposeSourceRouter), get_query_trend has no fallback to the query_stats_hourly / query_stats_daily continuous aggregates, so any request for more than ~4 days of history silently returns only whatever raw data hasn't aged out yet, with no indication in the response that the window was truncated.

Steps to Reproduce

  1. Identify a query_hash with execution history older than 4 days (via  get_top_queries_by_cpu or  get_query_store_top).
  2. Call get_query_trend with that query_hash, its database_name, server_name, and hours_back=168 (7 days).
  3. Inspect the returned trend array's collection_time values (min/max).

Expected Behavior

The trend array should span the full requested 168-hour (7-day) window — or, if the true retention horizon is genuinely shorter, the response should clearly indicate the effective window actually served (e.g. an  effective_hours_back / clamped flag) rather than silently returning less data than requested while echoing  hours_back: 168 unchanged.

Actual Behavior

The response reports  "hours_back": 168  but the trend array only contains data points spanning ~54 hours — the actual surviving span of the raw query_stats table, not the requested window. There is no field in the response signaling that the data was truncated or that only a subset of the requested window was returned. For comparison, get_query_store_top with the same hours_back=168  correctly returns data spanning the full 7 days, because it reads Query Store's own interval-identity data rather than the raw per-poll query_stats deltas.

Error Messages / Log Output

N/A

Screenshots

No response

Additional Context

 Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingTrendReader.cs  ( QueryHistorySql  /  GetQueryHistoryAsync ), invoked from  GetQueryTrend  in  Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpTrendTools.cs

Activity

  1. erikdarlingdata commented on Aug 19, 2026

    @erikdarlingdata
    Owner

    Confirmed, and your diagnosis is correct in every particular. I checked each claim against the code rather than reproducing from the description:

    1. TimescaleSupport.RawRetentionInterval = "4 days" — TimescaleSupport.cs:1678, with RawRetentionSpan = TimeSpan.FromDays(4) alongside it.
    2. DarlingTrendReader.QueryHistorySql reads the raw table only. Its FROM clauses are query_stats (lines 283 and 339) with no query_stats_hourly / query_stats_daily anywhere in the file.
    3. The rollups exist and something else already routes to them. ComposeCaggCatalog (Compose/ComposeSourceRouter.cs:103) maps query_stats to query_stats_hourly / query_stats_daily, and the router picks a tier "by the AGE of the window's oldest point, never by the display grain."

    So the tool advertises a window it structurally cannot serve, and the tier that could serve it is already built, already populated, and already reachable from a router twenty lines away.

    You undersold it — it does not just omit, it asserts

    The empty path is worse than silent truncation (DarlingMcpTrendTools.cs:227):

    return McpHelpers.Status("empty",
        $"No history found for query_hash '{query_hash}' in database '{database_name}' within the last {hours_back} hours.");

    For a query whose history aged out of the raw tier but is sitting in the hourly CAGG, this does not return a short array — it returns "No history found ... within the last 168 hours", which is a false statement rather than an incomplete one. An agent reading that concludes the query did not run. That is the worst possible failure for a monitoring tool: not "I have less than you asked for", but "it did not happen."

    That distinction is why this is a bug rather than a documentation gap, and it is the part I would fix first.

    Scope: it is this one reader, and you found the right one

    Worth stating so nobody widens this unnecessarily. Only the three rolled tables drop at the 4-day raw horizon — TimescaleSupport.cs:2130 says so directly, and ComposeCaggCatalog lists exactly query_stats, procedure_stats, query_store_stats. The other readers in DarlingTrendReader go through v_memory_stats / v_perfmon_stats / v_file_io_stats, which are plain passthroughs (CREATE OR REPLACE VIEW v_memory_stats AS SELECT * FROM memory_stats) over tables that keep their full configured retention. They read raw too, but their raw is the whole horizon, so they are unaffected.

    get_query_trend is the only tool in that file touching a rolled table's raw tier. You picked the single affected one out of five.

    The fix

    Two parts, and I want both rather than either:

    1. Route through the existing tiering. The query_stats CAGG carries database_name, query_hash and object_name as dimensions — get_query_trend filters on database_name and query_hash, both of which are in that set. So the aggregate can actually serve this query; this is wiring an existing router in, not building a new path.
    2. Report the window actually served. Even tiered, there is still a horizon (the daily CAGGs eventually end), so honesty cannot be a side effect of having enough data. The response should carry the effective window alongside the requested one, and the empty case must never claim absence over a span it did not read. Your suggested effective_hours_back is the right shape.

    Part 2 is small and removes the false statement; part 1 makes the data actually appear. Part 2 without part 1 would be honest and still unhelpful, which is why both.

    Timing

    Not in 3.5.0 — that build is in soak and I am not widening it. This lands on dev after the tag.

    One note on your version field: you reported 3.4.0, and I confirmed this is still present on dev today, so nothing about it is fixed in the meantime. Thank you for tracing it to the SQL rather than stopping at "the numbers look short" — the ComposeSourceRouter comparison is what made this a ten-minute confirmation instead of an afternoon.

  2. carlei1978 commented on Aug 19, 2026

    @carlei1978
    Author

    Thank you :)

    I'm not sure if this goes hand in hand with the above changes but I'm also encountering a query timeout when the MCP server runs some agg queries against the query_store data collected. I can see that's it hardcoded to 15s in PerformanceMonitor.Darling.Service\Compose\ComposeSpec.cs. Would you prefer a separate issue raised asking for the timeout to be increased (if possible)?

  3. erikdarlingdata commented on Aug 19, 2026

    @erikdarlingdata
    Owner

    Separate issue, and I have raised it for you: #2357. No need to file it — you have already done the hard part by finding the constant.

    You are right about the location. ComposeSpec.StatementTimeout = "15s" is the per-session statement_timeout on the viewer and mcp roles, and it is a genuinely different code path from this one, so it would not have been fixed alongside it.

    Two things I found while confirming it that are worth knowing now:

    Bumping the constant would not have helped you. It is applied in role PROVISIONING DDL — DarlingManagedRoles and tools/provision-roles.sql — deliberately not a versioned migration, because a role's statement_timeout has no probeable schema footprint and tying it to the schema version would break the viewer's connect-time version gate. Your roles already exist with 15s baked in, so a new build alone would change nothing. Any real fix has to re-apply it, and that is now part of #2357 rather than something you would have discovered the hard way.

    I would rather find out why it is slow before making the ceiling higher. ComposeSourceRouter already tiers compose panels raw → hourly → daily by the age of the window's oldest point. A panel timing out over a wide window is exactly what a tier that failed to route looks like — and that would be a bug in the same family as the one you reported here, where the fix is routing rather than waiting longer.

    So if you can add to #2357 which tool or panel timed out and roughly the window width you asked for, that tells us which of the two it is. If it turns out to be genuinely heavy work on a big store, the constant still should not be a constant — that part I am convinced of either way.

    The fix for this issue is up as #2356.

  4. erikdarlingdata commented on Aug 19, 2026

    @erikdarlingdata
    Owner

    Fixed in #2356. Thanks again @carlei1978 — your follow-up about the compose timeout is tracked separately as #2357.

    get_query_trend now routes to the hourly rollup when the window reaches past the raw tier's 4-day horizon, and the response carries source, effective_hours_back, bucket and truncated so it describes the data rather than echoing the request. The empty path no longer asserts absence over a span it never read.

    Two things worth recording. Columns the rollup does not carry come back null, never zero — on an aggregate row a zero reads as "none observed", a measurement nobody made. And writing the test caught the tiering rule being wrong on the first pass: comparing the window start against the window's END makes a two-hour window from ten days ago look recent — it is recent relative to its own end — and routes it to a tier that dropped those rows six days earlier. Retention answers to wall clock, so wall clock is the parameter.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions