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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user