Skip to content

De-skew the sixteen server-local timestamps MCP reads return, in both SKUs (#3206) - #3212

Merged
erikdarlingdata merged 14 commits into
devfrom
fix/3206-mcp-server-local-read-frames
Sep 9, 2026
Merged

erikdarlingdata merged 14 commits into
devfrom
fix/3206-mcp-server-local-read-frames

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

Fixes #3206.

Sixteen fields across five MCP tools returned the monitored server's LOCAL wall clock, unmarked, beside naive-UTC fields on the same JSON object. ToString("o") on a DateTimeKind.Unspecified value renders no offset suffix, so the two frames serialize identically and nothing in the payload distinguishes them — and server_properties.utc_offset_minutes is exposed by no MCP tool, so a caller could not correct for it either.

default_trace_events.event_time was the same class and was fixed by #3202. This is that PR's deliberately-deferred sibling list.

What changed

Tool Fields de-skewed Provenance of the local frame
get_running_jobs start_time RunningJobsCollector ships ja.start_execution_date, the msdb Agent clock, and computes current_duration_seconds against GETDATE() on the next line
get_blocking / Lite's get_blocked_process_reports blocked_/blocking_ × last_tran_started, last_batch_started, last_batch_completed the blocked-process-report XML's lasttranstarted / lastbatchstarted / lastbatchcompleted, and sys.dm_tran_active_transactions.transaction_begin_time on the DMV-fallback arm
get_index_usage last_user_access GREATEST of four sys.dm_db_index_usage_stats columns shipped verbatim
get_pvs_stats the four ADR cleaner start/end times sys.dm_tran_persistent_version_store_stats shipped verbatim
get_plan_corrections valid_since, last_refresh, execute_action_initiated_time, revert_action_initiated_time sys.dm_db_tuning_recommendations shipped verbatim

Payload field names are unchanged; only the frame is. That follows #3202: the C# row property gains a Utc suffix and the SQL projects a _utc alias, while the JSON key a caller keys on stays put.

The stored frame stays local. Collector dedup and duration arithmetic are local-vs-local, and CollectorTimestampFrameTests pins several of these columns that way; converting at the READ boundary is what the repo has settled on across ViewerDataService.SystemEvents, ViewerDataService.JobHistory, DarlingDataReader.CpuUtilizationSql, #2992 and #3202.

Darling de-skews in SQL, copying DarlingDefaultTraceReader's single-row COALESCE offset CTE (?? 0 when a server has not completed its first server_properties cycle, so a first-cycle server still gets its rows). Lite de-skews the row in C# at the MCP projection instead, for two reasons: DuckDB has no make_interval, and the underlying LocalDataService reads are shared with the WPF grids, which render through ServerTimeHelper and are wrong on a different subset of columns under #3207 — de-skewing in the data service would fix this surface by breaking that one. Lite takes the offset from McpServerLocalWindow.OffsetForAsync, never from ServerTimeHelper's process-wide static, for the reason #2967 established.

No row selection moves. Every affected read windows, snapshots and orders on a naive-UTC column, so the offset can only change values — which is why there is no stored-data migration and no schema change. That is asserted, not assumed (TheDeSkew_TouchesNoWindowBound_NoSnapshotSubquery_AndNoOrdering).

Measurement

The offset is exactly −240 minutes and it is fleet-wide: #2932 measured cpu_utilization_stats.sample_time at exactly 4 h behind the same collector run's collection_time on 42 of 42 servers.

Confirmed end-to-end at the read boundary on live get_pvs_stats payloads, three servers across both production stores. The off-row version cleaner runs continuously, so as_of − cleaner_time should be near zero:

store / server as_of (UTC) offrow_version_cleaner_start_time as returned apparent age true age after −240
A / server-x 2026-09-09T12:49:10.394 2026-09-09T08:49:01.700 4 h 00 m 08.7 s 8.7 s
A / server-y 2026-09-09T12:44:35.009 2026-09-09T08:44:34.426 4 h 00 m 00.6 s 0.6 s
B / server-z 2026-09-09T12:59:24.143 2026-09-09T08:58:42.763 4 h 00 m 41.4 s 41.4 s

The sub-second-to-41-second residual after subtracting 240 minutes is what rules out "the cleaner really did last run four hours ago". On server-z all four ADR-enabled databases read as 4 h stale — and get_pvs_stats' own description sends a caller there "when ADR cleanup looks stuck", so the phantom stall landed exactly on the question the tool exists to answer.

Verification

Darling.Tests and Lite.Tests target net10.0-windows and cannot execute on macOS, so CI is the arbiter for the xUnit run. What was actually run here:

  • All five projects build (service, viewer, Lite, both test projects), 0 errors, no new warnings.
  • Live PostgreSQL 17.11, throwaway container, stand-in tables carrying only the columns each read names. All six shipped SQL constants — dumped from the compiled assembly by reflection, not retyped — PREPAREd successfully, then executed against a seeded fixture:
    • server 1 at −240, with an older server_properties row at −300 present: every stamp came back local + 240, proving the CTE takes the newest offset and not just any.
    • server 2 at +330 (UTC+5:30 — east of UTC and not a whole hour): the same instants resolved to the same UTC values from the other side, which a whole-hour-west-only fixture would let a sign error survive.
    • server 3 with no collected offset: row still returned, value raw — the COALESCE … 0 fallback, and proof the cross join drops nothing.
    • NULLs stay NULL (the all-NULL GREATEST row, the absent blocking_last_batch_completed, the non-ADR database, the un-reverted recommendation).
    • plan_correction's enablement-only row (recommendation_name IS NULL) is still dropped, so the svr cross join did not disturb the filter.
  • Proven red without the fix. The same probe run against origin/dev's PvsStatsLatestSql, on the identical fixture, returns 07:59:59 beside a 12:00:00 collection_time — a 4 h 00 m 01 s phantom stall where the post-fix read returns 11:59:59, one second.
  • The new guard is proven red too, replayed against origin/dev's six SQL constants: 41 failing assertions without the change, all green with it. That replay caught a real defect in the guard's first cut — it derived the expected expression from the column alias, which silently asserted nothing for last_user_access, the one derived (GREATEST) column in the set. The guard now carries the de-skewed expression per column.

The guard, and the gap it closes

McpPayloadClockFrameDisciplineTests pins the payload boundary none of the three existing clock guards could reach:

  • CreationTimeClockFrameDisciplineTests has the right discriminator shape, but its roots are only Darling/PerformanceMonitor.Darling.Analysis and Lite/Analysis.
  • StoreSqlClockDisciplineTests has the right roots — Service/Mcp/ is in its globs — but hunts bare clock functions, and explicitly disclaims projections at its lines 43-47.
  • CollectorTimestampFrameTests has the right per-column reasoning but reads collector source only: it says what a frame IS and never who consumes it.

The gap is the diagonal, and it is what would have caught #3198, #2991 and all sixteen of these at once. Every fact is scoped to a (read, column) pair with the collector evidence named, because the frame genuinely cannot be keyed on the column name — StoreSqlClockDisciplineTests maintains AmbiguousFrameColumns for exactly that reason. Both directions are pinned: AlreadyUtcReads names the four sibling reads that must NOT acquire the de-skew, since applying it to a value that is already UTC is the same defect with the sign flipped. The desktop half of that mistake is #3207.

The cross-SKU arity check is deliberately a count and not a name map: the two SKUs legitimately spell the same column differently (snake_case SQL alias vs PascalCase row property), so a map would be either a second source of truth or a tautology. What must not drift is that both cover sixteen — a field de-skewed on one SKU and not the other is the one-sided port #2992 found nothing guarding against.

Instruction text

dev is already correct here and needs no edit from this PR. #3202 narrowed its own sentence before merging — DarlingMcpInstructions.cs now reads "windowed on event_time, which this tool returns in UTC", scoped to the tool it describes rather than to the surface.

An earlier revision of this description claimed dev still carried the un-narrowed "like every timestamp on every read here — is UTC" and that this PR would make it true. That quoted a pre-merge revision I had read before #3202 landed and never re-read afterwards; both halves of the claim were wrong, and it is corrected here rather than silently dropped.

The same universal recurred inside this PR's own diff and is fixed in cc14760: get_running_jobs' Description asserted start_time was "the same frame as collection_time and as every other timestamp this server returns". A tool Description ships in the MCP schema every client reads, so it reaches an agent even where the instruction text does not — and the clause is false by this PR's own census, which finds sixteen fields across five tools fixed here and six tools still returning server-local values. Every frame sentence this PR adds is now scoped to the tool it sits on. description-drift passes either way, because the claim was externally false rather than internally inconsistent.

Not in scope, filed separately: #3207 (the desktop renderers, wrong in opposite directions in both SKUs) and the render-projection half of #3208.

running_jobs.start_time, the six blocked-process transaction/batch stamps,
index_object_stats' last_user_access, the four PVS cleaner times and the four
plan-correction lifecycle times are all the monitored server's local wall clock
in the store, and every one was stamped into an MCP payload beside naive-UTC
fields on the same object.

Darling de-skews in SQL by the collected server_properties.utc_offset_minutes,
copying DarlingDefaultTraceReader's single-row COALESCE CTE. Lite de-skews the
row in C# at the MCP projection: DuckDB has no make_interval, and the
underlying LocalDataService reads are shared with the WPF grids, which have
their own frame defect on a different subset of columns.

Payload field names are unchanged; only the frame is. No read's window,
snapshot subquery or ordering touches an affected column, so no row selection
moves and there is no stored-data migration.

McpPayloadClockFrameDisciplineTests pins the boundary the three existing clock
guards could not reach: CreationTimeClockFrameDisciplineTests has the right
discriminator but only Analysis roots, StoreSqlClockDisciplineTests has the
right roots but hunts clock functions and disclaims projections, and
CollectorTimestampFrameTests says what a frame is without asking who consumes
it.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review

Went through the full diff (16-field de-skew across get_running_jobs, get_blocking/get_blocked_process_reports, get_index_usage, get_pvs_stats, get_plan_corrections in both Darling and Lite) plus the new McpPayloadClockFrameDisciplineTests guard. Checked specifically for the things this kind of change tends to break:

Sign convention — Darling's local - make_interval(mins => svr.offset_minutes) and Lite's .AddMinutes(-utcOffsetMinutes) both match the sign already established in DarlingDefaultTraceReader.EventsByWindowSql and LocalDataService.cs:238-239. No inversion.

Ordinal safety — the CTE additions don't shift any reader.GetDateTime(n) positional ordinal in DarlingBlockingReader, DarlingJobReader, DarlingPvsReader, or DarlingPlanCorrectionReader (all record constructors are positional; only DarlingBlockingReader's object-initializer properties needed the parallel rename, which is present).

No orphaned name-based consumer of the renamed aliases — grepped the whole repo for GetOrdinal("start_time")/["last_user_access"]-style name lookups and for other references to the six modified public const string ...Sql fields. Nothing outside each reader's own file + its MCP tool + its own test file touches them. DarlingAlertReadAdapter.AnomalousJobsSql and the Viewer's own RunningJobsSql/BlockedProcessReportsSql/etc. are independently-defined string constants (by design — the alert engine and collector dedup are intentionally local-vs-local per the PR description) and were correctly left untouched.

Cross-join row safety — every new WITH svr AS (SELECT COALESCE(...)) CTE has no FROM/WHERE on its outer SELECT, so it always yields exactly one row; the comma cross join to it can't fan out or drop rows. Verified against RunningJobsCollector.cs:56 that ja.start_execution_date (msdb sysjobactivity) really is the server-local Agent clock, confirming the premise for at least that one collector.

Lite/Darling parity — the TheTwoSkus_DeSkewTheSameNumberOfFields test pins both sides at 16, and the field lists in McpPayloadClockFrameDisciplineTests.ServerLocalPayloadColumns/LiteToolSites line up 1:1 with what's actually in the diff.

Nothing else stood out — no SQL injection surface (all params bound positionally), no missing-index folklore, no correctness issue in the GREATEST(...) + single-offset-subtraction algebra for last_user_access. Didn't find anything worth an inline comment.

🤖 Generated with Claude Code

…laration count read as a field count

Both were in the new guard, not the product.

EveryLiteToolSite_... required `Property?.AddMinutes(-utcOffsetMinutes)`, but
nullability is a property of the ROW TYPE and not of this convention:
RunningJobRow.StartTime is a non-nullable DateTime and ships without the `?.`,
while the other fifteen are DateTime? and ship with it. The assertion was
therefore vacuous for the one field that cannot carry it. The matcher now makes
the null-conditional optional and is pinned in both directions on both forms,
plus a prefix-collision case.

TheTwoSkus_DeSkewTheSameNumberOfFields compared Darling's per-READ column
declarations (18) against Lite's payload-field count (16). get_blocking is
served by two reads and the always-on DMV fallback arm re-serves
blocked_/blocking_last_tran_started -- the two columns a DMV snapshot has -- so
the sums were never going to agree. It now compares DISTINCT payload fields and
pins the overlap at exactly 2, so a third read quietly re-serving a field has to
be a deliberate edit.

The local replay harness has grown the Lite scan and the arity check it lacked,
which is why neither of these surfaced before CI: it only ever replayed the
Darling facts. Both halves are now proven red without the fix -- 41 assertions
on the Darling constants, 37 on origin/dev's Lite sources.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewed the diff (cb208f6 → PR tip) file-by-file: both blocking readers, the job reader, object-stats reader, plan-correction reader, PVS reader, all five Darling MCP tool consumers, all five Lite MCP tool consumers, McpServerLocalWindow, and the new McpPayloadClockFrameDisciplineTests.

Correctness: Sign convention matches the established DarlingDefaultTraceReader pattern exactly (- make_interval(mins => svr.offset_minutes) in Postgres, .AddMinutes(-utcOffsetMinutes) in C#), and the single-row COALESCE CTE shape is copied verbatim across all five Darling readers. Verified ordinal positions in every reader.GetDateTime(n) / IsDBNull(n) call still line up after the column renames — no read gained or lost a column, only the alias changed. Checked that the two record types with a non-nullable DateTime (RunningJobRow.StartTimeUtc in Darling, RunningJobRow.StartTime consumption in Lite's McpJobTools) correctly drop the ?. — this was the exact bug the second commit fixed, and it's fixed correctly in both SKUs. Traced BlockedProcessReadRow : BlockedProcessAlertRow to confirm the renamed properties (BlockedLastTranStartedUtc etc.) don't collide with or hide anything on the shared PerformanceMonitor.Alerting base class — they don't, the base class carries none of the renamed fields.

Lite/Darling parity: All 16 fields are covered on both sides (confirmed by reading the actual tool files, not just trusting the test). The asymmetry — Darling de-skews in SQL, Lite de-skews in C# at the MCP projection layer rather than in LocalDataService — is deliberate and well-justified (DuckDB has no make_interval; LocalDataService reads are shared with WPF grids that render through ServerTimeHelper, which has its own unrelated frame bug tracked in #3207). Confirmed the Viewer's independent RunningJobRow/IndexUsageRow/etc. classes (which stay server-local, by design) are separate types from the ones renamed here, so nothing on the WPF viewer path was accidentally touched.

No row-selection changes: confirmed every touched read still windows/snapshots/orders on a naive-UTC column (collection_time), matching the PR's own claim and the new test's TheDeSkew_TouchesNoWindowBound_... assertion.

Security: no new user input paths, all params still bound via $1/$2/... placeholders, nothing string-concatenated.

One minor style nit posted inline: three of the five new Postgres cross joins (DarlingObjectStatsReader, DarlingPlanCorrectionReader, DarlingPvsReader) don't alias their main table, while the other two touched in this same PR (DarlingBlockingReader, DarlingJobReader) do — a 2-vs-3 inconsistency introduced within this diff itself. Not a bug, doesn't block.

Otherwise this is a clean, well-tested fix — the guard test file is unusually thorough (positive controls on its own regexes, a floor-and-ceiling census, an explicit "must NOT acquire the de-skew" list) and the PR description's own account of the two bugs CI caught in the guard's first cut checks out against the current code.

…nit2008)

Nineteen new analyzer warnings, all in the new guard. The two message-carrying
assertions are deliberately left as Assert.True/False: the analyzer does not
flag them, and Assert.Matches takes no message, so converting them would drop
the diagnostic that names the collector evidence.
CONTRIBUTING.md's T-SQL style says table aliases always use AS, and the other
three reads this change touches already do. Aliasing v_index_object_stats AS
ios, v_pvs_stats AS pvs and plan_correction AS pc removes a 2-vs-3 split
introduced inside one diff, and gives each snapshot self-subquery its own
alias (latest) so the correlated and outer scopes read apart.

The guard tracks the SHIPPED expression for its one derived column, so
last_user_access's declared source moves with the qualification -- which is why
it is declared rather than derived from the alias.

Verified unchanged against live PostgreSQL 17.11 on the same fixture: the same
de-skewed values, NULLs preserved, the enablement-only row still dropped, and
the unused-first ordering intact.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewed the diff against CONTRIBUTING.md. Summary:

Scope check — no T-SQL collector files are touched by this PR (only Darling's Postgres MCP reads and Lite's C# MCP projection layer), so the T-SQL style rules (AS on aliases, OPTION(RECOMPILE)) don't apply here. The Postgres CTE style (FROM x AS y, svr implicit cross join) matches the pre-existing convention in DarlingDefaultTraceReader rather than introducing a new one — confirmed via grep that this comma-join-with-a-single-row-CTE pattern predates this PR.

Correctness

  • Verified the offset arithmetic sign by hand against the PR's own measurement table: utc_offset_minutes is stored negative for a behind-UTC server (e.g. −240 for EST), so local - make_interval(mins => offset_minutes) = local - (-240min) = local + 240min, which correctly converts local→UTC. Lite's AddMinutes(-utcOffsetMinutes) is the same operation. Consistent across all 16 fields, both SKUs.
  • Checked ordinal-position reader mapping (reader.GetDateTime(n)/IsDBNull(n)) in every touched Darling reader (DarlingJobReader, DarlingBlockingReader, DarlingObjectStatsReader, DarlingPvsReader, DarlingPlanCorrectionReader) against the new column lists — no ordinal drift, since the CTE only replaces an existing column's expression rather than adding/removing a projected column.
  • GREATEST(...) in IndexUsageSql correctly ignores NULLs and only nulls out when all four inputs are NULL (Postgres semantics), matching the doc comment; subtracting the interval after GREATEST is equivalent to subtracting before it since all four share one offset.
  • The svr CTE is a single-row, no-FROM SELECT COALESCE(...), so the comma cross join can't multiply or drop rows — verified this holds for every one of the six touched reads.
  • Confirmed BlockedProcessAlertRow (the shared alert-engine base class) does not declare the renamed *Utc properties, so the rename on DarlingBlockingReader.BlockedProcessReadRow doesn't shadow/break PerformanceMonitor.Alerting consumers, which intentionally keep working in the local frame.

Lite/Darling parity — traced all five affected tools (get_running_jobs, get_blocking/get_blocked_process_reports, get_index_usage, get_pvs_stats, get_plan_corrections) on both SKUs plus the adjacent get_blocked_process_xml/get_blocking (Darling) tools that share the same row types, and didn't find a field left raw on one SKU while fixed on the other. The new McpPayloadClockFrameDisciplineTests guard (floor+ceiling census, both-direction discriminators, red-without-the-fix replay) is unusually thorough for this kind of drift and looks like it actually closes the gap it claims to.

Security — all Postgres access uses positional parameters ($1/$2/$3) via NpgsqlDataSource/AddWithValue; no string interpolation of caller input into SQL.

Minor, non-blocking observation: in the five Lite MCP tool sites (McpJobTools, McpObjectStatsTools, McpPvsTools, McpPlanCorrectionTools, McpBlockingTools), McpServerLocalWindow.OffsetForAsync(...) is awaited unconditionally before the row fetch, even on the path where rows.Count == 0 and the offset ends up unused. Since Lite's store is embedded DuckDB rather than a network round trip, this is essentially free — not worth restructuring, just noting it for completeness since perf regressions were in scope.

No blocking issues found.

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review summary

Went through the full diff (five Darling MCP readers + tools, five Lite MCP tools, and the new McpPayloadClockFrameDisciplineTests guard) against CONTRIBUTING.md's T-SQL/PostgreSQL conventions and the Lite/Darling parity requirement. No correctness, security, or parity issues found. Specifically verified:

  • Offset sign convention: column - make_interval(mins => svr.offset_minutes) in Darling and .AddMinutes(-utcOffsetMinutes) in Lite are consistent with each other and with the documented utc_offset_minutes semantics (measured −240 on the fleet; both forms correctly add 240 minutes to de-skew).
  • Row-selection safety: every de-skewed read still windows/snapshots/orders on the naive-UTC collection_time (or the report XE event_time), never on a de-skewed column — matches the PR's claim of "no row selection moves" and is independently pinned by TheDeSkew_TouchesNoWindowBound_....
  • The svr offset CTE is guaranteed single-row (SELECT COALESCE((...),0) AS offset_minutes with no FROM), so the new comma cross-joins (FROM x AS t, svr) can't multiply or drop rows. This mirrors the precedent already established in DarlingDefaultTraceReader (De-skew default_trace_events.event_time to UTC at every Darling read boundary (#3198) #3202), including the un-aliased , svr join style.
  • No stale references: grepped for every renamed property (BlockedLastTranStarted, StartTime→StartTimeUtc, LastUserAccess→LastUserAccessUtc, ValidSince/LastRefresh/ExecuteActionInitiatedTime/RevertActionInitiatedTime→*Utc, PVS cleaner times→*Utc) across Darling/PerformanceMonitor.Darling.Service — none left. The one hit outside the Mcp/ tree (PvsStatsStoreTests.cs using non-Utc cleaner-time names) is a distinct ViewerDataService-sourced row type, unrelated to the renamed DarlingPvsReader.PvsStatsRow.
  • Reader ordinal mappings (e.g. DarlingObjectStatsReader.GetIndexUsageAsync, DarlingBlockingReader.GetRecentBlockedProcessReportsAsync) are unaffected by the renames since column count/position didn't change — only aliases did.
  • Lite/Darling parity: both SKUs cover the same 16 fields (Darling: 18 (read, column) pairs due to the DMV-fallback arm re-serving 2 columns; Lite: 16 direct), and the new guard test (TheTwoSkus_DeSkewTheSameNumberOfFields) pins the arity rather than a fragile name map, which is the right call given Darling's snake_case SQL aliases vs. Lite's PascalCase row properties.
  • AlreadyUtcReads (RecentDeadlocksSql, IndexUsageMatchCountSql, PvsTrendSql, AutomaticTuningSql) checked against source — correctly excluded (XE @timestamp, count-only, or collection_time-only reads).
  • Consumers outside Mcp/: the underlying views/tables (v_running_jobs, v_index_object_stats, v_pvs_stats, plan_correction) are also read by DarlingAlertReadAdapter.AnomalousJobsSql and the alert engine, but those are separate SQL string constants not touched by this PR, so no cross-surface breakage.
  • T-SQL/Postgres style: AND alignment, AS on table aliases, schema-agnostic (Postgres, not SQL Server — OPTION(RECOMPILE) doesn't apply here), all consistent with the codebase's existing Darling conventions.

One trivial, non-blocking nit: RunningJobsSql's inner MAX(collection_time) self-subquery wasn't given the AS latest alias treatment that IndexUsageSql and PvsStatsLatestSql picked up in this same PR (for clarity against the new outer rj alias) — harmless since there's no actual ambiguity, just a minor style inconsistency within the diff itself.

🤖 Generated with Claude Code

… one unaliased cross-join form

Two separate corrections.

get_running_jobs' Description asserted start_time is "the same frame as
collection_time and as every other timestamp this server returns". The second
clause is false by this PR's own census -- five tools are fixed here, six still
return server-local values -- and a tool Description ships in the MCP schema
every client reads, so it reaches an agent even where the instruction text does
not. This is the class the PR exists to remove, restated inside it. Now scoped
to the two fields on this payload, matching what Lite's same-named tool already
said. The reader's row doc carried the same universal and is scoped too.

All six reads now cross join svr WITHOUT aliasing the main table, which is what
they did before the aliasing attempt. Full qualification made IndexUsageSql's
filter textually diverge from IndexUsageMatchCountSql's, and
IndexUsageTruncationTests.TheCountAndTheRowsShareTheirFilter checks those two
statements agree BY TEXTUAL IDENTITY -- the count has no cross join, so it was
never aliased. Relaxing that pin to accept divergent text would remove its
ability to catch a real divergence later, and the count read applying a
different filter than the rows is exactly what #2636 was about. svr contributes
only offset_minutes and is always referenced as svr.offset_minutes, so nothing
is ambiguous unaliased. The AS-on-every-table style question is #3217.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewed. This is a solid, well-tested fix and I didn't find correctness, Lite/Darling parity, or security issues.

Checked specifically:

  • Sign/direction of the de-skew: local - make_interval(mins => svr.offset_minutes) AS col_utc (Darling) and .AddMinutes(-utcOffsetMinutes) (Lite) both match the existing convention from DarlingDefaultTraceReader (get_default_trace_events filters in UTC but returns event_time in server-local time, unmarked #3198/De-skew default_trace_events.event_time to UTC at every Darling read boundary (#3198) #3202) — local - offset = UTC. Consistent everywhere it's applied.
  • No double-conversion: verified LocalDataService's underlying rows (BlockedLastTranStarted, ValidSince, etc.) are still raw/local — the WPF grid renders them through ServerTimeHelper unconverted (the separate Desktop clock frames are wrong in opposite directions in both SKUs, and each SKU is right where the other is wrong #3207 defect) — so the MCP-layer AddMinutes in Lite's tool files is the only place the conversion happens, not a second application on top of an already-converted value.
  • No stale references: grepped for old (non-Utc-suffixed) property names (.BlockedLastTranStarted, .LastUserAccess, .ValidSince, .AbortedCleanerStartTime, etc.) across Darling/PerformanceMonitor.Darling.Service/Mcp/*.cs — none remain outside the record definitions themselves.
  • Row selection unaffected: every touched read still windows/snapshots/orders on the naive-UTC collection_time, never on a de-skewed column — matches the TheDeSkew_TouchesNoWindowBound_NoSnapshotSubquery_AndNoOrdering guard's own claim.
  • Cross join safety: the svr CTE's SELECT COALESCE((...), 0) AS offset_minutes has no FROM/WHERE at its outer level, so it always returns exactly one row — the FROM x, svr comma-join can't drop or multiply rows. This matches the pre-existing pattern in DarlingDefaultTraceReader.
  • Parity: 16 distinct fields on both sides; Darling's 18 (read, column) declarations vs 16 distinct fields is explained by the DMV-fallback arm re-serving 2 of the 6 blocking columns, which the new Darling.Tests guard pins explicitly. Lite reaches the same 6 through a single merged LocalDataService call, so it's inherently free of that duplication — not a parity gap.
  • Style: no T-SQL style-guide violations — this only touches Postgres SQL and C#, and the expression AS alias shape matches the established Darling PostgreSQL-reader convention (not the T-SQL column_name = expression convention, which is scoped to T-SQL per CONTRIBUTING.md).

One minor, non-blocking observation: CHANGELOG.md doesn't yet have an [Unreleased] entry for this fix, unlike its siblings #3198/#3202 which each got one. Worth adding for consistency if this repo's release process expects it.

…ed census

#3220's guard landed on dev while this branch was open, and its inventory was
written against the unrepaired state. Two of its facts fail here, both because
this PR changes what the scan finds rather than because anything regressed.

Thirty rows move McpPayloadUnmarked -> DeSkewedAtRead, keeping each row's
original reasoning and suffixing the disposition -- relabelled rather than
deleted, which is the convention its own DeSkewedAtRead summary asks for and
what #3202's two rows already do: the payload field still carries the column's
name while the value is now naive UTC, so the scan still finds the site.

The two last_user_access rows come OUT instead. That payload field was only ever
reachable through the projection-alias hop, and the read's alias is now
last_user_access_utc, so RenamedServerLocalProjections yields the _utc name and
the payload's own field name is no longer derivable. A row for a site the scan
cannot see would break set equality from the other side. The alias pin follows
the read.

One row stays McpPayloadUnmarked and should:
Lite/Mcp/McpPlanCacheSchedulerTools.cs's oldest_plan_create_time. It is a
Lite-only surface -- Darling's plan-cache tool does not emit it -- and #3206's
census missed it because that census enumerated Darling's emissions and
intersected with Lite, which by construction cannot see a Lite-only tool. Left
unrepaired here deliberately: it belongs to a different collector and a tool
this PR does not otherwise touch. #3206 is corrected to say so.

The class doc's "Nothing here is repaired by this change" is no longer true of
the MCP half and now says which half is which.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewed the diff (all Darling reader/tool files, Lite MCP tool files, and both test files).

Correctness: Traced every one of the 16 de-skewed fields end to end — SQL alias → column ordinal → C# reader → JSON payload key — for get_running_jobs, get_blocking/get_blocked_process_reports, get_index_usage, get_pvs_stats, and get_plan_corrections on both SKUs. Ordinal positions are preserved everywhere the SQL select-list only changed an alias, so no reader/row mapping is out of sync. The offset sign convention (local - make_interval(mins => offset_minutes) = utc) is self-consistent between Darling's SQL and Lite's AddMinutes(-utcOffsetMinutes). Confirmed via grep that no .ToString("o") call was missed on any of the fields the PR claims to convert.

Row-selection safety: Checked every touched query's WHERE/snapshot-subquery/ORDER BY — all still key off naive-UTC collection_time/event_time, never off a de-skewed alias, so the PR's claim of "no row selection changes, only values" holds up under inspection (and is also pinned by TheDeSkew_TouchesNoWindowBound_NoSnapshotSubquery_AndNoOrdering).

Blocking merge: BlockedProcessReportMerge/BlockedProcessAlertRow (the shared XE↔DMV dedup/merge used by the alert engine too) only touches the base alert-row fields, not the six renamed *Utc properties on BlockedProcessReadRow, so the rename can't perturb dedup/merge behavior.

Lite/Darling parity: Both SKUs convert the same 16 fields (verified the arithmetic behind TheTwoSkus_DeSkewTheSameNumberOfFields: 18 Darling (read, column) declarations − 2 for the DMV-fallback's shared columns = 16 distinct fields, matching Lite's 16). The one remaining unmarked field (McpPlanCacheSchedulerTools.oldest_plan_create_time) is correctly left alone — it has no Darling counterpart, and the PR explicitly documents it as out of scope.

Minor/non-blocking: McpServerLocalWindow.OffsetForAsync (a DuckDB query, opens its own connection) is now called unconditionally before the main data query in all 5 newly-touched Lite tools, so a request that ends up returning "no data" still pays for the offset lookup. This matches the pre-existing pattern for get_cpu_utilization/get_default_trace_events, so it's not a new defect class, just extended — not blocking.

No SQL-injection, secrets, or missing-index concerns — all reads are parameterized, and no new folklore index advice was added. Nothing else stood out; the new McpPayloadClockFrameDisciplineTests.cs guard is unusually thorough (both-direction discriminator tests, a floor+ceiling census, and a positive control against the matchers themselves going stale).

…ad of dropping two

Removing the two last_user_access rows was the wrong remedy, and the reason is
in the guard's own words: "without this the label is a way to delete a site from
the census by asserting it is fine." Relabelling without evidence deletes a site
by asserting it is fine; removing it deletes the site without even asserting.

The obstacle was that the read's alias was last_user_access_utc while the
payload field stayed last_user_access, and the census reaches that payload field
THROUGH the SQL alias. Flipping the alias's inherited frame to UTC does not fix
that -- RenamedServerLocalProjections drops a non-ServerLocal alias entirely, so
the site would still vanish from the register.

So the read's alias goes back to last_user_access and keeps its de-skew. The
payload field and the alias coincide again, the site stays visible, and the
alias pin needs no change from what dev already had. This one alias deliberately
carries no _utc suffix, and that is now the stronger arrangement: a suffix is a
claim in a name that nothing checks, while
EveryDeSkewedAtReadSite_CarriesItsConversionInTheReaderItDependsOn pins the
conversion text itself.

That fact's evidence table therefore grows from 2 rows to 34, one per
DeSkewedAtRead row, each naming the reader it depends on and the conversion as
shipped -- Darling's SQL de-skew and Lite's C# AddMinutes, including the one
non-nullable StartTime that carries no null-conditional. The table was generated
from the inventory and every conversion asserted present in its own file before
being written, so it is derived from the shipped text rather than transcribed.

McpPayloadUnmarkedSites 33 -> 1, DeSkewedAtReadSites 2 -> 34. The 22 render
sites are untouched.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewed the diff (all 18 files, both product code and the new/updated test guards) against CONTRIBUTING.md and for Lite/Darling parity, correctness, security, and perf.

Correctness of the de-skew direction: verified value - make_interval(mins => svr.offset_minutes) against the PR's own measurement (offset ≈ −240, local reads 240 min behind true UTC) — the arithmetic recovers UTC correctly, and it matches the existing convention from DarlingDefaultTraceReader/#3202.

Ordinal safety: for every touched reader (DarlingBlockingReader, DarlingJobReader, DarlingObjectStatsReader, DarlingPlanCorrectionReader, DarlingPvsReader) the new WITH svr AS (...) CTE and the renamed output aliases don't shift the reader.GetDateTime(N) ordinals used by the row mappers — confirmed by reading the mapping code, not just the SQL. IndexUsageMatchCountSql (the sibling count query) correctly stays without the svr cross join, so it can't double-count rows — good, since that's exactly the kind of thing a cross join to a CTE could silently break.

Lite/Darling parity: Darling de-skews in SQL, Lite de-skews in C# at the MCP projection (McpServerLocalWindow.OffsetForAsync) for the documented reasons (no make_interval in DuckDB, and LocalDataService reads are shared with the WPF grids). Checked all five affected tool pairs (get_running_jobs, get_blocking/get_blocked_process_reports, get_index_usage, get_pvs_stats, get_plan_corrections) — both sides touch the same fields, and the MCP tool Description text was updated symmetrically on both SKUs.

Security: all reads stay parameterized ($1/$2/$3 / DuckDB equivalents); no string concatenation of database_name or other caller input into SQL.

Row-selection safety: every touched read still windows/snapshots/orders on the naive-UTC collection_time, never on a de-skewed column — checked this by hand on top of the PR's own TheDeSkew_TouchesNoWindowBound_NoSnapshotSubquery_AndNoOrdering guard, so the change can't silently move which rows come back.

No correctness, parity, or security issues found. The one intentionally-deferred item (Lite/Mcp/McpPlanCacheSchedulerTools.cs's oldest_plan_create_time) is explicitly disclosed in the PR body and the test inventory as Lite-only with no Darling counterpart to port against, so it's not an oversight.

Nit (non-blocking): computing utcOffsetMinutes via McpServerLocalWindow.OffsetForAsync happens unconditionally before the empty-row check in all four Lite tool methods, so an empty result still pays for one extra offset lookup. Trivial cost against an embedded DuckDB store, not worth restructuring.

…uffix

The suffix was the whole cause of both remaining failures, and reverting it on
one alias only fixed one sixteenth of it.

RenamedServerLocalProjections reads a projection alias's SOURCE columns and
cannot see the conversion, so every *_utc alias over an unambiguously
server-local column was reported as inheriting a server-local frame. That took
the pinned list from one entry to sixteen and pulled alias sites into the
found-set that the inventory does not carry. Dropping the suffix makes the alias
equal to the column name, which that helper explicitly skips
(byColumn.ContainsKey(alias)), so every site routes through the ordinary column
path where the frame check applies and the register answers correctly. Its pin
needs no edit: it matches what dev already had.

The suffix is not missed. It was a claim in a name with nothing checking it,
whereas EveryDeSkewedAtReadSite_CarriesItsConversionInTheReaderItDependsOn pins
the conversion text in the reader it depends on -- now realigned to the shipped
aliases for all fifteen. #3202's event_time_utc keeps its suffix and is
untouched: event_time spans five tables and two frames, so the helper declines
it and the collision never arises there.

Not doing the other half deliberately: teaching that helper about de-skews would
now be unexercised, because no projection reaches the code path once every alias
matches its column. It wants a live case to be pinned against.

Verified on live PostgreSQL 17.11 that all six reads still parse and execute; an
alias name cannot move a computed value, and the value arithmetic was verified
in detail earlier in this branch.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review: de-skew of server-local MCP timestamps (#3206)

Reviewed the production code changes (both SKUs) against CONTRIBUTING.md's conventions and the correctness/parity/security/perf criteria. Summary of what I checked and confirmed:

Correctness

  • All five Darling readers (DarlingBlockingReader, DarlingJobReader, DarlingObjectStatsReader, DarlingPlanCorrectionReader, DarlingPvsReader) apply the exact same single-row COALESCE(... ORDER BY sp.collection_time DESC LIMIT 1), 0) offset CTE and - make_interval(mins => svr.offset_minutes) subtraction already established and shipped by De-skew default_trace_events.event_time to UTC at every Darling read boundary (#3198) #3202 (verified against DarlingDefaultTraceReader/ViewerDataService.SystemEvents) — sign convention and fallback-to-0 behavior are consistent with precedent, not a fresh (and riskier) invention.
  • Verified column-ordinal reader mapping by hand for BlockedProcessReportsSql (35 cols) and DmvBlockingSnapshotsSql (20 cols) against the reader.GetX(n) calls — no off-by-one.
  • The GREATEST(...) de-skew in IndexUsageSql is applied once, after the GREATEST, which is correct since all four source columns share one server offset; NULL propagation (GREATEST all-NULL → NULL, interval subtraction of NULL → NULL) is correct.
  • No row-selection logic changed — every window/snapshot/order-by predicate still keys off naive-UTC collection_time, confirmed by reading each query; this matches the PR's own claim and its TheDeSkew_TouchesNoWindowBound_... guard.
  • DarlingPlanCorrectionReader.GetPlanCorrectionsAsync binds window parameters via DateTime.SpecifyKind(..., DateTimeKind.Unspecified) per the Darling Postgres style rule in CONTRIBUTING.md.
  • All Postgres params are bound ($1/$2/...), no string concatenation — no injection surface.

Lite/Darling parity

  • Confirmed the same 16 fields are de-skewed on both SKUs (Darling in SQL at the CTE, Lite in C# via McpServerLocalWindow.OffsetForAsync + AddMinutes(-utcOffsetMinutes) at the MCP projection). Checked that McpServerLocalWindow.OffsetForAsync/GetServerUtcOffsetMinutesAsync were pre-existing (already used by get_cpu_utilization/get_default_trace_events), not new plumbing.
  • Confirmed the DMV-fallback arm of blocking (both SKUs) shares the same row property as the XE arm, so the single de-skew at serialization covers both sources correctly.
  • Spot-checked for orphaned old property names (BlockedLastTranStarted etc. without Utc suffix) across Darling/ — the only non-Utc hits are in ViewerDataService.Blocking.cs, which is the WPF viewer's own row type explicitly out of scope here (deferred to Desktop clock frames are wrong in opposite directions in both SKUs, and each SKU is right where the other is wrong #3207), and test files. No stray reference to a renamed record property.
  • The one Lite-only field left McpPayloadUnmarked (McpPlanCacheSchedulerTools.oldest_plan_create_time) has no Darling counterpart to port against, and is called out explicitly as such in the updated inventory/tests — not a silent parity gap.

Security / perf

I didn't find correctness bugs, parity drift, or security/perf regressions in the diff. The one thing outside my ability to verify here is the actual CI run (couldn't fetch check-run status in this environment) — worth confirming the Darling PostgreSQL live-Postgres job and the Windows Lite.Tests/Darling.Tests job are green before merge, per the new McpPayloadClockFrameDisciplineTests guard.

… the prose is not read as SQL

Both remaining failures came from a tool Description, not from any SQL.

ConsumedTimestampFrameDisciplineTests.ProjectionAlias is
([^\r\n]*?)\s+AS\s+([a-z][a-z0-9_]*) under IgnoreCase, so it matches any
" as <word>". get_running_jobs' Description said "start_time is UTC, the same
frame as the collection_time on this payload" -- which reads as a projection
whose expression names start_time and whose alias is "the", with Lite's
variant yielding "collection_time". Both were reported as renamed server-local
projections, taking the pinned list to three entries and pulling alias sites
into the found-set the inventory does not carry.

This is a string LITERAL, so WithoutComments does not touch it and should not:
the SQL under test lives in literals too. The prose is the thing to change, and
"matching" says it without the word the regex keys on. Replaying the helper over
both SKUs' Mcp/ trees now returns exactly [(last_user_access, index_object_stats)],
which is the pinned value.

Not changed: that regex reading prose at all is a latent looseness rather than
this PR's defect -- four comment lines in these same readers already matched it
on dev without being reportable. Applying the file's own WithoutComments in that
helper would close the comment half and is provably a no-op today, so it wants
its owner's call rather than a drive-by from here.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewed the diff (Darling readers/tools, Lite MCP tools, and the new McpPayloadClockFrameDisciplineTests guard).

Correctness: The de-skew direction (local - make_interval(mins => svr.offset_minutes) AS ... on Darling, .AddMinutes(-utcOffsetMinutes) on Lite) is consistent both ways and matches the existing DarlingDefaultTraceReader/event_time_utc convention. Traced the renamed record properties (BlockedLastTranStartedUtc, StartTimeUtc, LastUserAccessUtc, etc.) through their constructors and confirmed no ordinal/position mismatches, and confirmed the Lite-side conversion happens only at the MCP projection layer (LocalDataService still returns the raw local value, so there's no double-conversion risk). The GREATEST(...) NULL-propagation and the single-row COALESCE fallback-to-0 CTE both check out. IndexUsageMatchCountSql was correctly left untouched (no timestamp, and touching it would have re-created the #2636 count/rows filter-divergence the existing IndexUsageTruncationTests guards against).

No row-selection changes: confirmed every affected read still windows/snapshots/orders on the naive-UTC collection_time/server_id, not on a de-skewed column — matches the PR's own claim and its guard test.

Lite/Darling parity: functionally in parity (same 16 fields de-skewed on both SKUs, same McpServerLocalWindow.OffsetForAsync pattern reused rather than ServerTimeHelper's process-wide static). Left two small inline nits where the newly-added description text itself diverges between the two SKUs' otherwise-identical tool docs — low severity, doc-only.

Nothing blocking found. The guard/test work here is unusually thorough (live-Postgres verification across three offsets including a non-whole-hour one, and the guard is proven red against origin/dev first).

Comment thread Lite/Mcp/McpObjectStatsTools.cs Outdated
Comment thread Lite/Mcp/McpPlanCorrectionTools.cs Outdated
Both new UTC clauses dropped their trailing comparison on the Lite side, so the
two SKUs' descriptions stopped being word-for-word identical. Lite ships all
three tools referenced - get_collection_log in McpHealthTools, list_servers in
McpDiscoveryTools, get_query_store_regressions in McpQueryTools - so the
comparisons are equally valid here. A tool Description reaches an MCP caller
where instruction text does not, which is this change's own argument for saying
the whole thing.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review

Went through all 18 changed files (both Darling readers/tools and their Lite counterparts) plus the two test files. Summary: I did not find correctness, parity, or security issues.

What I checked specifically:

  • Sign convention. utc_offset_minutes is collected the same way in both SKUs (DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) in ServerPropertiesCollector.cs, i.e. local − utc). Darling converts with value - make_interval(mins => svr.offset_minutes); Lite converts with value.AddMinutes(-utcOffsetMinutes). Both implement utc = local − offset, correctly, for both negative (west-of-UTC) and positive (east-of-UTC) offsets — consistent with the ±330/−240 fixture cases described in the PR body.
  • NULL handling. The GREATEST(...) → subtract chain in DarlingObjectStatsReader.IndexUsageSql correctly stays NULL when all four source columns are NULL (Postgres NULL - interval = NULL), and the four inputs sharing one offset makes "subtract after GREATEST" equivalent to "subtract before." Lite's ?.AddMinutes(...) mirrors this per-row.
  • Row selection untouched. Every de-skewed read still windows/snapshots/orders on the naive-UTC collection_time (or event_time for the DMV-fallback arm), never on the de-skewed columns — confirmed by reading each query's WHERE/ORDER BY, matching the guard added in McpPayloadClockFrameDisciplineTests.TheDeSkew_TouchesNoWindowBound_NoSnapshotSubquery_AndNoOrdering.
  • Lite/Darling parity. All sixteen fields are covered symmetrically in both SKUs; McpPayloadClockFrameDisciplineTests.TheTwoSkus_DeSkewTheSameNumberOfFields pins the arity (16 fields, 18 Darling (read, column) pairs because the DMV-fallback blocking arm re-serves 2 of the 6 blocking columns). The one remaining McpPayloadUnmarked site (McpPlanCacheSchedulerTools.cs oldest_plan_create_time) is Lite-only with no Darling counterpart, correctly left out of scope.
  • The offset CTE (WITH svr AS (SELECT COALESCE((... ORDER BY collection_time DESC LIMIT 1), 0) ...)) is a single-row COALESCE cross-joined into each query, matching the existing pattern already used by DarlingDefaultTraceReader/ViewerDataService.SystemEvents — not a new idiom, so no new risk there.
  • Security: no user input reaches these queries beyond the existing parameterized server_id/window params; nothing new here.

Minor, non-blocking observation: in each of the five Lite MCP tools (McpJobTools, McpBlockingTools, McpObjectStatsTools, McpPlanCorrectionTools, McpPvsTools), McpServerLocalWindow.OffsetForAsync(...) is now called unconditionally before the row-count check, so a call that returns zero rows still pays for the offset lookup. Given the offset query is a cheap point lookup and this mirrors the same ordering across all five tools, I don't think it's worth restructuring — just flagging in case you'd rather fetch it lazily only when rows are non-empty.

The self-referential test coverage (McpPayloadClockFrameDisciplineTests, positive+negative regex controls, the arity/ceiling assertions, and the updated ConsumedTimestampFrameDisciplineTests census) is unusually rigorous and I couldn't find a gap in it. Nice work.

@erikdarlingdata
erikdarlingdata merged commit 849ea2e into dev Sep 9, 2026
8 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/3206-mcp-server-local-read-frames branch September 9, 2026 18:04
erikdarlingdata added a commit that referenced this pull request Sep 9, 2026
…ansition (#3231)

server_properties.utc_offset_minutes is DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())
-- the offset in force at COLLECTION time, one current value. Subtracting it
from a stored timestamp is exact only while both sit on the same side of a DST
transition, and #3212 shipped sixteen de-skewed fields without saying so on the
two reads where that can be false.

Thirteen of the sixteen describe current state -- a running job, an open
transaction, a cleaner that ran seconds ago -- so their timestamps and that
offset are always in the same period. get_index_usage is the exception and the
worst case: sys.dm_db_index_usage_stats persists since the instance restarted,
which on a stable production box is routinely months, so a large share of
last_user_access values predate the most recent transition and come back 60
minutes early. Silently, and in the plausible direction.

The exposure is general rather than particular: any target in a DST-observing
zone has it and a UTC-configured target does not, and on AWS RDS the instance
takes its time zone from a creation-time parameter, so a non-UTC zone is
ordinary configuration rather than an exotic setting -- which also kills the
plausible-sounding assumption that an RDS instance is probably UTC. #2932
carries the measurement behind the four-hour figure. sqlserver_start_time on the
same row bounds how far back the affected values reach.

get_pvs_stats is a tier less severe and gets the same note: the off-row cleaner
runs continuously, but aborted_version_cleaner_* can reach back weeks on a quiet
database, so it can cross a transition.

Documented rather than fixed, deliberately. The proper fix is a ZONE instead of
an offset -- CURRENT_TIMEZONE_ID() collected alongside utc_offset_minutes, then
AT TIME ZONE at the read boundary, which handles transitions for a timestamp of
any age -- and that is a collected column plus a migration rung, which is its
own change. There is no backfill: the collector ships the DMV value verbatim and
the offset is subtracted in the projection, so nothing stored is wrong today.
The offset path stays as the fallback regardless, because CURRENT_TIMEZONE_ID()
is SQL Server 2019+ and the product supports older servers.

What this carries is the #2993 shape: the scope of the claim, stated where the
de-skew happens, so the next reader knows the frame is exact inside the current
DST period and within an hour outside it.
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