Skip to content

The trend tools answer in time buckets sized to the window: a day of get_file_io_trend is 34 KB, not 1.4 MB (#3897) - #3968

Merged
erikdarlingdata merged 5 commits into
devfrom
feature/3897-trend-tool-points
Sep 23, 2026
Merged

erikdarlingdata merged 5 commits into
devfrom
feature/3897-trend-tool-points

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #3897. Closes #3962.

Why

The trend tools returned every collection in the window, so an answer's size was window × cadence × series. Measured on DARLING01:

  • get_file_io_trend at its 24 h default (SQL2022) was 12,500 points and 1.4 MB, and a week was 9.8 MB.
  • get_lock_wait_trend was 349 KB (2 MB over a week), and 96% of its rows (3,368 of 3,523) were zeros.
  • get_pg_io_trend / get_pg_database_trend were 448 KB / 294 KB over a day.

The store was never the bottleneck. EXPLAIN ANALYZE of every read on DARLING01 came in at 270 ms or less at 168 h. The one exception is get_pg_io_trend's automatic subject choice (3.8 s at 168 h, filed as #3961).

Where the diagnosis needed correcting (measured)

  • File I/O. get_file_io_trend already capped itself at the top 10 files, so the "600 files → about 30 MB" extrapolation does not hold. What scaled it was the window: 9.8 MB at 168 h on any server. It also published indistinguishable rows. tempdb's nine data files shared one database name per timestamp, which accounts for 11,250 of 12,500 rows, and the web chart's pivot kept one of each.
  • Lock waits. get_lock_wait_trend does scale with the number of LCK types: one row per type per collection.
  • Rollups. Only query_stats and procedure_stats have a rollup tier, and the duration pair already routes to it. None of the other three reads has anything to route to.

What changes

One shared contract (PerformanceMonitor.Common/Mcp, both SKUs):

  • TrendBuckets picks the width from a ladder (1, 2, 5, 10, 15, 30 minutes, 1 to 24 hours).
    • It takes the narrowest width that keeps the answer within 200 points, counted across series and including the edge bucket.
    • bucket_minutes (1–1440) asks for another width, up to a per-read cap sized so the largest answer stays under 256 KB.
    • A width out of range or over the cap is refused, never clamped, and the refusal names the narrowest width that fits.
    • PostgreSQL date_bin and DuckDB time_bucket share one origin, so both SKUs bin the same rows the same way.
  • TrendPayloads builds the file I/O and lock-wait envelopes once for both SKUs.
  • Every bucketed payload says what a point is: bucket, bucket_minutes, aggregate_note.
  • Aggregation rules:
    • Counts are summed.
    • Rates are the bucket's summed work over its summed seconds.
    • Ratios are ratios of sums, never averages of averages.
    • peak_* / worst_* keep the single most extreme collection, so a spike survives.
    • A collection with no knowable interval is left out of both the numerator and the denominator, and is counted.

Per tool:

  • get_file_io_trend
    • A line is a (database, file type) pair, ranked by the window's I/O stall.
    • The top five get their own lines. The rest fold into one (other) line, pooled collection by collection before bucketing. All lines are charted when there are six or fewer.
    • database_name narrows to one database, charted per file.
    • Latency is summed stall over summed operations: null over none, where it was a fabricated 0.00.
    • A series legend gives each line's window totals.
    • Restart rows (stored interval 0) neither rank nor chart.
  • get_lock_wait_trend
    • The LCK family is one series, summed per collection over that collection's ONE interval.
    • A wait_types legend lists the types that waited, and wait_types_idle counts the rest.
    • A window of collected but idle types is a measured zero, not empty.
  • get_query_duration_trend / get_procedure_duration_trend
    • The raw tier is time-weighted, with unknowable collections counted in unrated_collections.
    • The hourly tier gathers whole hours. A width that is not a whole number of hours is refused there.
    • peak_elapsed_ms_per_second is the busiest collection, or the busiest hour on the rollup.
    • effective_start is the first collection, not a bucket boundary.
  • get_pg_io_trend / get_pg_database_trend
    • Intervals are gathered into buckets. point_count now counts buckets, and the new interval_count is the old count.
    • Every window figure is counted from the buckets' own sums, peaks and interval counts, so it is identical at any width. Those figures are resets, spilling intervals, the worst interval and when it happened, and the peak spill and read rates.
    • Per-point peak_* / worst_cache_hit_pct.

Surfaces:

  • Web viewer. /api/read passes bucket_minutes through; an unparseable value is refused. It charts at a 1,500-point budget, so a day stays per-minute on one line. The file I/O panel pivots by line, and the PostgreSQL trend tables gain peak/worst columns.
  • Lite. DuckDB twins live in LocalDataService.TrendBuckets.cs. The desktop charts' per-collection reads are untouched.
  • get_query_heatmap. Its bucket_minutes and metric refusals (both SKUs) were bare sentences with no invalid status. They are now McpHelpers.Refusal.
  • Refusal census (closes The refusal census cannot see a bare sentence returned through a private helper #3962). The two holes those bare sentences slipped through are closed:
    • It now walks every ?? operand, not just the first.
    • It follows a tool's return Helper(…) into the same-file helper and holds each result arm of that helper's return expressions to the bare-sentence and hand-built rules. Ternaries are split at depth zero and conditions excluded. 60 helpers are followed on the tree today, floored at 50, and a fixture proves the walk fires on the heatmap's old shape and not on its neighbours.
  • Token budget (The MCP surface costs ~100k tokens of context before the first call: 157 tools, 336 KB of tools/list, 86 KB of instructions #3898). Instructions and descriptions are rewritten for the new shape. Descriptions, parameter descriptions and instruction rows for these tools, on both SKUs, total 508 characters fewer than before (24,824 → 24,316), bucket_minutes included.

Payload changes (deliberate, since "results identical" is not possible here)

  • File I/O: a new grain and keys, as above.
  • Lock waits: trend rows no longer carry wait_type; the per-type view is wait_types.
  • Duration trends:
    • bucket is a width word.
    • aggregate_note is always set.
    • New keys: bucket_minutes, unrated_collections, peak_elapsed_ms_per_second.
    • The unrated_note sentence now counts collections, not points.
  • PostgreSQL pair:
    • point_count counts buckets, and interval_count is new.
    • A point's interval_seconds is the seconds its intervals covered.
    • New per-point peak/worst keys.

No store migration.

Test plan

  • Before → after on DARLING01 (read-only: my build's readers through a harness against the live store, 2026-09-22):
Tool 24 h default, before → after 168 h, before → after
get_file_io_trend (SQL2022) 1,398 KB → 34.3 KB 9,790 KB → 34.3 KB
get_lock_wait_trend (SQL2022) 349 KB → 17.9 KB 1,959 KB → 20.8 KB
get_query_duration_trend (SQL2022) 160 KB → 32.6 KB (72 h: 487 → 32.5 KB) 29.1 KB → 34.7 KB (hourly tier)
get_pg_io_trend (PG18) 448 KB → 54.2 KB 3,157 KB → 63.3 KB
get_pg_database_trend (PG18) 294 KB → 39.8 KB 2,046 KB → 46.4 KB
  • Exactness on DARLING01, same fixed as_of, my build vs the deployed build:
    • The PostgreSQL window figures are identical, and interval_count equals the old point_count.
    • One-minute duration buckets equal the old per-collection rates on 1,251 and 1,237 buckets.
    • The hourly tier is identical at 60 minutes.
    • The lock family per minute equals the sum of the old per-type rates.
  • Payload census (TrendPayloadBudgetLiveTests, new): a week of one-minute collections plus a 600-file server.
    • Every default answer is 8–69 KB, against a pinned ceiling of 80 KB.
    • The largest answer each read's cap admits is 196–249 KB, against a pinned ceiling of 320 KB.
    • The old reads fail it by an order of magnitude.
  • New tests:
    • TrendBucketsTests: the width table, the budget invariant over every window (1–168 h) × line count (1–6) at both budgets, the edge bucket, the refusals naming the width that fits, and the hourly tier.
    • TrendBucketingLiveTests (Darling, live) and TrendBucketingToolTests (Lite) plant the same rows and assert the same hand-worked figures: the fold per collection, ratios of sums, null over no I/O, the restart row, time-weighting, unrated collections, and the window-start stamp.
    • The rewritten DarlingLockWaitTrendTests / LockWaitTrendToolTests: family plus legend, one interval per collection, and the all-idle window.
    • TryParseOptionalInt.
  • Census and pin updates:
    • TrendBuckets producers are accepted as shared refusals, with executed shapes.
    • Every ?? operand is walked.
    • The Lite differenced-trend census covers the new file.
    • The shared unrated-note sentence is re-pinned.
  • Full Darling suite, live (PostgreSQL 18.4 + TimescaleDB 2.28.1), on the final tree: 12,674 total, 0 failed, 22 skipped. Every skip is environment-gated: DARLING_TEST_SQL, DARLING_TEST_PGRUNTIME, the log target, or symlink rights.
  • Full Lite suite: 5,131 total. 0 failed on the run before the rebase onto get_store_metrics returns ~1.9 MB by default (every store object × ~30 days) — ~500k tokens, larger than any agent context; even days_back=1 is 346 KB #3903/Lite's daily-summary contract test no longer fails in the first two minutes after midnight UTC #3963, which touched no Lite code under this change. The final run had 2 lock-timing failures, StatusBarSizeReadLockTests.GetUsedDataSizeMb_WhenTheWriteLockIsHeld_GivesUpInsteadOfBlocking and AnalysisPassTokenThreadingTests.TheReadLockWaitIsAbandonableWhileAWriterHoldsIt ("the writer never took the lock"). Both passed 3/3 when re-run alone: the known AnalysisPass threading flake family.
  • 0 warnings, both test projects.

Folded in: #3962, the refusal census's helper blind spot.

Still open, and why:

🤖 Generated with Claude Code

https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif

erikdarlingdata and others added 4 commits September 22, 2026 21:18
#3897)

get_file_io_trend, get_lock_wait_trend, get_query_duration_trend,
get_procedure_duration_trend, get_pg_io_trend and get_pg_database_trend
returned every collection: a default day of file I/O was 12,500 points and
1.4 MB, a week 9.8 MB. Each now buckets its collections on a shared origin,
sized to a 200-point budget, with bucket_minutes to ask for another width up
to a per-read cap (refused, never clamped). Counts are summed, rates and
ratios recomputed from the bucket's sums, the worst single collection kept as
the peak; the payload says what a point is (bucket, bucket_minutes,
aggregate_note). File I/O charts (database, file type) lines ranked by stall
with the tail folded into one (other) line, database_name for one database
per file; lock waits chart the LCK family with a per-type legend. Lite parity
in DuckDB; the web viewer reads the same tools at a chart budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif
…and (#3897)

get_query_heatmap's bucket_minutes and metric refusals went through local
helpers, the bucket one behind a ?? chain whose shared first operand the
refusal census accepted for the whole chain. Both are now McpHelpers.Refusal
at the call site on both SKUs, and the census checks every operand of a ??
chain. The payload census gains a 600-file server; the web PostgreSQL trend
tables show each bucket's peak and worst interval; two docs lines describe
the bucketed shape.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif
The CoalesceOperands helper landed between LastAssignmentOf's doc comment and
its signature, stacking two summaries on one member (DocCommentHygieneTests).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif
…3962)

The bare-sentence and hand-built-envelope rules only saw a tool's own return
expressions, so a bare sentence returned through a same-file private helper
passed - get_query_heatmap's refusals hid that way until #3897. The census now
follows each `return Helper(...)` into the method declared in the same file
and holds every result arm of its return expressions (ternaries split at depth
zero, conditions excluded) to the same two rules. Floored at 50 helpers
followed (60 on the tree today); a fixture proves the walk fires on the
heatmap's old shape and not on a helper that builds the refusal, a miss built
through McpHelpers.Status, a ?. / ?? expression, or a method in another file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 23, 2026 01:53
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…ection-health policy run waits out a launch that beat the park (#3982)

* Chunk-exclusion tests count a bitmap plan's chunks once, and the collection-health policy run waits out a launch that beat the park

Two CI flakes that blocked #3968 and #3972 on code neither PR touched.

LatestValueLookbackLivePostgresTests reported "planned 4 chunks" for a
24-hour read whose plan scanned two. TimescaleDB names a chunk's indexes
after the chunk, so under a bitmap plan the "Bitmap Index Scan on
_hyper_12_11_chunk_..._idx" child matched the chunk pattern too, and the
count doubled whenever the planner picked bitmap scans. The same pattern
lived in FleetReadsAreBoundedByTheFleetTests and CaptureDownChunkOrderTests.
One shared PlanChunkScans helper now ends the match on the chunk name, and
PlanChunkScansTests pins it against the plan CI printed.

CollectionHealthAggregateTests' run_job failed 55P03 "concurrent refresh":
collection_health_hourly's policy has no initial_start, so TimescaleDB
launches its first run the moment the product commits it, before the
fixture can park it. RunPolicyAsync now retries 55P03 for up to 30 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* The chunk counters #3980 added go through PlanChunkScans too

#3980 merged to dev with two more ChunkScans callers in
LatestValueLookbackTests and a fourth copy of the double-counting pattern
in PgTargetConfigSnapshotBoundTests. Both now use the shared helper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 3c92e61 into dev Sep 23, 2026
15 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/3897-trend-tool-points branch September 23, 2026 03:25
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…3920, #3927, #3931, #3932, #3940, #3942, #3946, #3947, #3950, #3952, #3955, #3956, #3957, #3964, #3965, #3966, #3968, #3972, #3975, #3979, #3980, #3981, #3983, #3984, #3985) (#3989)

The wave's fix PRs deliberately carried no CHANGELOG edits (parallel-agent hot-spot protocol); each agent reported its entry and this commit lands them together, byte-verified against origin/dev.


Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
… PostgreSQL query duration) buckets to a point budget, the same way #3968 bucketed the first five (#3960) (#4007)

Reuses TrendBuckets/TrendBudget/TrendPayloads from #3968 rather than inventing a second mechanism.
Gauges (memory, tempdb, CPU) publish the bucket's average with peak_* beside it; get_wait_trend and
get_perfmon_trend's rate points sum work over summed seconds, never averaging per-collection rates.
get_memory_trend's grant join moves from nearest-snapshot-within-30-seconds to same-bucket, averaging
every grant snapshot in a memory point's bucket with the largest kept as peak_granted_mb.

Extends the #3968-era test infrastructure for the new tools: TrendBucketsTests' cap sweep,
TrendPayloadBudgetLiveTests' payload census (seeds and measures all six), and a new
TrendFamilyBucketingLiveTests with hand-verified bucket math for each read's distinctive shape
(wait's time-weighting, CPU's unclamped own-clock bucketing, tempdb's averaged-space/busiest-session
split, perfmon's three-way interval-completeness shaping, the PG duration pair's recomputed mean).

Fixes fallout in existing census/inventory tests from centralizing per-tool JSON building into the
shared TrendPayloads builders: DarlingPgReadSqlParsesLiveTests (a shared SQL fragment reflected as a
standalone query), ConsumedTimestampFrameDisciplineTests (a de-skewed site moving out of its per-SKU
file), PerfmonCounterTypeRungTests and DarlingCustomViewsTests (bucket_minutes changing a payload
shape a fixed-count assertion assumed), and their Lite twins.


Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant