Fix worker dropping the OnDataChange source timestamp

MXAccess fires OnDataChange with pftItemTimeStamp marshaled as a
VT_BSTR string (e.g. "3/26/2026 1:38:22.907 PM"), not a FILETIME or
VT_DATE. VariantConverter classified it as a plain string, so
ApplySourceTimestamp never set MxEvent.SourceTimestamp — every
OnDataChange event and every cached ReadBulk result carried no
source timestamp for all gRPC clients.

Parse the string and set SourceTimestamp. MXAccess formats it in the
worker host's local time (verified empirically: a fast-changing tag's
timestamp landed exactly the host UTC offset behind wall-clock UTC),
so it is parsed as local and converted to UTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-21 13:53:38 -04:00
parent c1fe7fbc4a
commit 099d4783b0
2 changed files with 105 additions and 0 deletions
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
@@ -179,6 +180,63 @@ public sealed class MxAccessEventMapperTests
Assert.Null(mxEvent.OnAlarmTransition.OriginalRaiseTimestamp);
}
/// <summary>
/// Verifies that an OnDataChange whose timestamp arrives as the
/// VT_BSTR string MXAccess actually delivers still populates
/// <see cref="MxEvent.SourceTimestamp"/> — the string is parsed as
/// local time and converted to UTC.
/// </summary>
[Fact]
public void CreateOnDataChange_WithMxAccessStringTimestamp_SetsSourceTimestamp()
{
// The exact shape MXAccess fires (see captures/003-subscribe-scalars).
const string mxAccessTimestamp = "3/26/2026 1:38:22.907 PM";
MxEvent mxEvent = mapper.CreateOnDataChange(
"session-1",
serverHandle: 1,
itemHandle: 1,
value: 99,
quality: 192,
timestamp: mxAccessTimestamp,
statuses: null);
Assert.NotNull(mxEvent.SourceTimestamp);
DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified);
DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime();
Assert.Equal(expectedUtc, mxEvent.SourceTimestamp.ToDateTime());
}
/// <summary>
/// Verifies the MXAccess timestamp string is interpreted as the host's
/// local time and returned as UTC. Written timezone-independently by
/// round-tripping a local wall-clock time.
/// </summary>
[Fact]
public void TryParseSourceTimestamp_InterpretsStringAsLocalTime()
{
DateTime localWall = new(2026, 5, 21, 13, 43, 26, DateTimeKind.Unspecified);
string text = localWall.ToString(CultureInfo.CurrentCulture);
Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc));
Assert.Equal(DateTimeKind.Utc, utc.Kind);
DateTime expectedUtc = DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime();
Assert.Equal(expectedUtc, utc);
}
/// <summary>Verifies unparseable or empty timestamp input is rejected without throwing.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not a timestamp")]
public void TryParseSourceTimestamp_RejectsUnparseableInput(string? text)
{
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _));
}
private sealed class FakeStatus
{
public int success;
@@ -1,4 +1,6 @@
using System;
using System.Globalization;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.Conversion;
@@ -265,6 +267,19 @@ public sealed class MxAccessEventMapper
return;
}
// MXAccess fires OnDataChange with pftItemTimeStamp marshaled as a
// VT_BSTR string (e.g. "3/26/2026 1:38:22.907 PM"), not a FILETIME or
// a VT_DATE — so the variant converter classifies it as a plain
// string and the timestamp would otherwise be dropped. Parse it here
// so the source timestamp still reaches MxEvent. MXAccess formats the
// string in the worker host's local time; see TryParseSourceTimestamp.
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.StringValue
&& TryParseSourceTimestamp(convertedTimestamp.StringValue, out DateTime parsedUtc))
{
mxEvent.SourceTimestamp = Timestamp.FromDateTime(parsedUtc);
return;
}
if (!string.IsNullOrWhiteSpace(convertedTimestamp.RawDiagnostic))
{
mxEvent.RawStatus = string.IsNullOrWhiteSpace(mxEvent.RawStatus)
@@ -273,6 +288,38 @@ public sealed class MxAccessEventMapper
}
}
/// <summary>
/// Parses an MXAccess <c>OnDataChange</c> timestamp string into a UTC
/// <see cref="DateTime"/>. MXAccess delivers the value as a culture-
/// formatted string rather than a FILETIME or VT_DATE, and formats it
/// in the worker host's <em>local</em> time (verified empirically — a
/// fast-changing tag's timestamp lands the host's UTC offset behind
/// wall-clock UTC). The parsed value is therefore taken as local time
/// and converted to UTC. Tries the worker host's culture first
/// (MXAccess formats with the host locale), then the invariant culture.
/// </summary>
/// <param name="text">The MXAccess timestamp string.</param>
/// <param name="utc">The parsed UTC timestamp on success.</param>
/// <returns><see langword="true"/> when the string parsed successfully.</returns>
internal static bool TryParseSourceTimestamp(string? text, out DateTime utc)
{
utc = default;
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed)
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
{
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
return true;
}
return false;
}
private MxArray ConvertBufferedArray(
object? value,
MxDataType expectedElementDataType)