Skip to content

Scope the de-skew's accuracy claim where the column outlives a DST transition - #3229

Closed
erikdarlingdata wants to merge 2 commits into
devfrom
fix/deskew-dst-scope
Closed

Scope the de-skew's accuracy claim where the column outlives a DST transition#3229
erikdarlingdata wants to merge 2 commits into
devfrom
fix/deskew-dst-scope

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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 the timestamp and the offset sit on the same side of a DST transition. #3212 shipped sixteen de-skewed fields without saying so on the two reads where that can be false.

Raised by another session reading the merged code, and confirmed here against DarlingServerConnector.cs:94 and the fleet's reported offset.

Why get_index_usage is the case that matters

Thirteen of the sixteen describe current state — a running job, an open transaction, a cleaner that ran seconds ago — so their timestamps and the collected offset are always in the same DST period, and the de-skew is exact.

last_user_access is not current state. It is the GREATEST of four sys.dm_db_index_usage_stats columns that persist since the instance restarted, which on a stable production box is routinely months. So a large share of those values predate the most recent transition and come back 60 minutes early — silently, and in the plausible direction, which is the bad one. sqlserver_start_time on the same row is the bound on how far back it can reach.

The fleet is exposed rather than theoretically exposed: any target in a DST-observing zone has it — the offset differs by an hour between summer and winter, so every pre-transition timestamp is de-skewed by an offset that was not in force when it was written. A target configured to UTC is unaffected, and on AWS RDS the instance takes its time zone from a creation-time parameter, so a non-UTC zone is an ordinary configuration rather than an exotic one. #2932 records the measurement behind the four-hour figure above.

get_pvs_stats gets the same note one tier down: the off-row cleaner runs continuously and is unaffected in practice, but aborted_version_cleaner_* can reach back weeks on a quiet database — a live read showed 2026-08-26 against a 2026-09-09 snapshot — so it can cross a transition.

Documented rather than fixed, deliberately

The proper fix is a zone instead of an offset: CURRENT_TIMEZONE_ID() (SQL Server 2019+) collected alongside utc_offset_minutes, then AT TIME ZONE at the read boundary, which handles transitions correctly for a timestamp of any age. That is a collected-column addition, a migration rung, and swapping the - make_interval(...) expression for an AT TIME ZONE conversion in five readers, with the offset kept as the fallback while server_timezone_id is null — which it will be for every server until its next server_properties collection.

There is no backfill, and an earlier revision of this description wrongly said there was. Nothing stored needs rewriting: the label is DeSkewedAtRead precisely because the collector ships us.last_user_seek verbatim and the offset is subtracted in the projection, so no stored column is wrong today. And a single current zone ID is legitimately valid retroactively where a single current offset is not — that asymmetry is the whole defect. utc_offset_minutes changes twice a year, so today's value is wrong for half of history; CURRENT_TIMEZONE_ID() returns a zone identity that is stable for the life of the server, so collecting it going forward and applying it to already-stored rows is sound. It is the one field whose present value describes the past.

Two things that stay genuinely open, so this does not read as free: CURRENT_TIMEZONE_ID() is SQL Server 2019+, so the offset path has to be kept rather than replaced — the product supports servers older than that and will for someone regardless of any particular deployment — and a server whose Windows time-zone data disagrees with the historical rule for a given date is still wrong for that date. Much narrower than the current failure, not zero.

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. Same family as #2993 by a different mechanism — there a zone was captured and discarded, here an offset is captured and over-applied, and both end with a naive timestamp confidently placed in the wrong frame.

Verification

Doc-only; no shipped SQL or behaviour changes. Both affected projects build with 0 errors and both files keep CRLF with no bare LF.

One check worth naming because it is a live trap in this area: ConsumedTimestampFrameDisciplineTests.ProjectionAlias is IgnoreCase over " as <word>" and reads string literals and comments, so ordinary prose next to a census column name registers as a SQL projection alias — it cost #3212 two CI rounds. I ran that exact regex over every added line and no added prose produces a reportable match.

…ansition

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.

Most of the sixteen describe current state -- a running job, an open
transaction, a cleaner that ran seconds ago -- so the timestamp and the 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 fleet is exposed
rather than theoretically exposed -- its targets report utc_offset_minutes of
-240, which is EDT, a DST-observing zone on summer time -- and
sqlserver_start_time on the same row is the bound on how far back it reaches.

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 (a live read showed 2026-08-26 against a 2026-09-09 snapshot), 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 -- and that is a
collected-column addition plus a migration rung, which is its own change. 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.

Same family as #2993 by a different mechanism: there a zone was captured and
discarded, here an offset is captured and over-applied. Both end with a naive
timestamp confidently placed in the wrong frame.
/// keeps it NULL. This read returns NO other timestamp, which is why converting rather than labelling
/// matters more here than elsewhere: there is nothing else in the payload for a reader to notice a
/// disagreement against.</para>
/// <para><b>The de-skew is exact only inside the current DST period, and this is the read where that

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lite/Parity: this same DST-exactness caveat applies to Lite, and Lite's description isn't updated.

Lite/Mcp/McpObjectStatsTools.cs computes last_user_access with the identical pattern: GetServerUtcOffsetMinutesAsync reads the latest utc_offset_minutes (itself DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) at collection time — same single-current-value offset as Darling's server_properties.utc_offset_minutes), then applies it via r.LastUserAccess?.AddMinutes(-utcOffsetMinutes) against a DMV column that persists since the last instance restart. That's the exact scenario this PR documents as being 60 minutes off across a DST transition — but Lite's tool description (McpObjectStatsTools.cs:57) just says "this read de-skews them" with no scope caveat.

Since this PR is intentionally scoped to documenting rather than fixing, should the same caveat be added to Lite's description (or a follow-up issue opened) so the two apps don't drift on a documented-vs-undocumented known limitation?

/// phantom four-hour stall is the worst place for an unmarked frame. The trend read needs nothing: it
/// returns only <c>collection_time</c>.</para>
///
/// <para><b>Exact only inside the current DST period.</b> The collected offset is the one in force at

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same parity gap as the DarlingObjectStatsReader note: Lite/Mcp/McpPvsTools.cs de-skews aborted_version_cleaner_start_time/_end_time the same way (AddMinutes(-utcOffsetMinutes) off a single collected offset, McpPvsTools.cs:77-78), off a value that "can reach back weeks on a quiet database" per this PR's own reasoning — so the same DST-crossing exactness limitation applies there too, but Lite's tool description (McpPvsTools.cs:26) doesn't carry the scope note this PR adds to Darling.

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Review

This is a doc-only PR (XML doc comments, no SQL or logic changes) — verified by diff: only DarlingObjectStatsReader.cs and DarlingPvsReader.cs changed, 24 additions / 0 deletions, both in /// doc comments.

Technical claims checked against the code and hold up:

  • server_properties.utc_offset_minutes is indeed a single current-value offset (DATEDIFF(MINUTE, GETUTCDATE(), GETDATE()) at collection time), fetched via ORDER BY collection_time DESC LIMIT 1 in the SQL in DarlingObjectStatsReader.IndexUsageSql — confirms the "one current value applied to timestamps of any age" claim.
  • The four GREATEST-combined last_user_access source columns do come from sys.dm_db_index_usage_stats, which persists since the last instance restart — consistent with the claim that these can predate the most recent DST transition while the other de-skewed reads (current state) cannot.
  • No SQL/behavior changes, consistent with the stated intent (documenting scope, not fixing).

Lite/Darling parity gap (posted as inline comments): the exact same de-skew mechanism and exact same exactness limitation exist in Lite —

  • Lite/Mcp/McpObjectStatsTools.cs (get_index_usage / last_user_access, via LocalDataService.GetServerUtcOffsetMinutesAsyncAddMinutes(-utcOffsetMinutes))
  • Lite/Mcp/McpPvsTools.cs (aborted_version_cleaner_start_time/_end_time, same pattern)

Both are the same "single collected offset applied to a DMV value that can predate the last DST transition" shape this PR documents for Darling, but Lite's tool descriptions weren't updated with the same scope caveat. Since the PR is explicitly about documenting rather than fixing, worth deciding whether Lite gets the same doc note now or via a tracked follow-up — otherwise the two apps end up with one documented limitation and one silent one for the identical bug.

No correctness, security, or performance concerns — this is comment-only.

…tate's composition

The note justified itself with a deployment fact -- the fleet's reported offset
and its zone. A shipped product's source should not reason from one estate's
composition: the exposure is that ANY target in a DST-observing zone has it, and
on RDS a non-UTC zone is an ordinary creation-time parameter rather than an
exotic setting, which also kills the plausible-sounding assumption that an RDS
instance is probably UTC. #2932 already carries the measurement, so the general
argument loses nothing and is checkable without a private fact.

Same for the version gate: the offset path stays because the product supports
servers older than SQL Server 2019, not because any particular fleet does or
does not contain one.
@erikdarlingdata

Copy link
Copy Markdown
Owner Author

Superseded by #3231 — same content, opened fresh as a single commit.

Closing rather than pushing more commits: this branch's first commit carried a sentence that justified the exposure by a deployment fact, and the follow-up commit removed it from the source without removing it from that commit. #3231 carries only the generalised form, in one commit, with the same file content — the staged blobs are byte-identical to this branch's tip.

To be accurate about what this achieves: it limits what a future reader encounters and keeps the sentence out of dev's permanent ancestry, which merging this branch would have made unavoidable. It is not erasure — the old commit stays reachable by SHA for a while and this timeline records that it existed.

@erikdarlingdata
erikdarlingdata deleted the fix/deskew-dst-scope branch September 9, 2026 18:50
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewed. This is a genuine doc-only change: no SQL, no C# logic, only XML-doc prose added to DarlingObjectStatsReader.cs and DarlingPvsReader.cs. Verified independently:

  • The technical claim is correct: svr.offset_minutes in both IndexUsageSql and PvsStatsLatestSql is a single most-recent utc_offset_minutes value (ORDER BY collection_time DESC LIMIT 1), applied uniformly via a make_interval subtraction regardless of how old the timestamp being adjusted is. For last_user_access (GREATEST of four sys.dm_db_index_usage_stats columns, which persist since the last instance restart) and aborted_version_cleaner_* (can be weeks old per the PR's own example), that is a real 60-minutes-early exposure across a DST boundary, same as the pre-existing documented pattern in DarlingJobReader.cs and DarlingDefaultTraceReader.cs.
  • The ProjectionAlias regex trap called out in the description does not fire: every added line was checked for an as-word pattern case-insensitively and there were zero matches.
  • CRLF: the repo .gitattributes normalizes these via text=auto eol=crlf, so the blob-level LF vs working-tree CRLF distinction is expected and not an issue.

One gap worth flagging: Lite/Darling parity of the documentation itself. The identical bug exists in Lite, undocumented:

  • Lite/Mcp/McpObjectStatsTools.cs line 98: the LastUserAccess AddMinutes(-utcOffsetMinutes) call is the exact same single-current-offset subtraction applied to sys.dm_db_index_usage_stats columns that can be months old.
  • Lite/Mcp/McpPvsTools.cs lines 77-80: same pattern for the four ADR cleaner timestamps.

A search for DST/daylight mentions across Lite returns nothing, so there is no equivalent note anywhere in Lite calling out that these two tools' timestamps are only exact inside the current DST period. Since this PR's whole point is documenting rather than fixing the limitation, and the described fix (collected zone plus AT TIME ZONE) would need to land in both stores' migration ladders per the Two-Store Parity contract anyway, it would be worth adding the same caveat to the two Lite call sites (even as a short inline comment, matching Lite's terser style) so a future Lite-side fix is not scoped by someone who never saw this analysis. Not a blocker for this PR as scoped, but leaving Lite's docs silent on a defect this PR just spent several paragraphs proving is real reads as an oversight rather than a deliberate choice.

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