diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs
index 4224573..8539640 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Conversion/MxStatusProxyConverterTests.cs
@@ -135,6 +135,90 @@ public sealed class MxStatusProxyConverterTests
Assert.Equal("Invalid reference", second.DiagnosticText);
}
+ ///
+ /// Verifies every accessor strategy converts the same status values
+ /// identically. PERF-20 reads a public field of a publicly visible type
+ /// through a delegate compiled from an expression tree instead of
+ /// FieldInfo.GetValue; a field type whose conversion to int is not
+ /// a lossless widening () and a type the
+ /// compiled delegate may not touch (, not
+ /// visible outside this assembly) keep the reflection read. All four
+ /// doubles below carry the same logical status, so all four messages must
+ /// be equal.
+ ///
+ [Fact]
+ public void Convert_AcrossAccessorStrategies_ProducesIdenticalMessages()
+ {
+ // Struct, widening field types: the compiled accessor unboxes in place.
+ MxStatusProxy compiledStruct = _converter.Convert(new FakeMxStatusProxy
+ {
+ success = 1,
+ category = 5,
+ detectedBy = 3,
+ detail = 21,
+ });
+
+ // Reference type, widening field types: the compiled accessor casts.
+ MxStatusProxy compiledClass = _converter.Convert(new FakeStatusProxyClass
+ {
+ success = 1,
+ category = 5,
+ detectedBy = 3,
+ detail = 21,
+ });
+
+ // Non-widening field types: reflection + Convert.ToInt32 is kept.
+ MxStatusProxy reflectedWide = _converter.Convert(new FakeWideStatusProxy
+ {
+ success = 1L,
+ category = 5L,
+ detectedBy = 3L,
+ detail = 21L,
+ });
+
+ // Type not visible outside the assembly: reflection is kept.
+ MxStatusProxy reflectedHidden = _converter.Convert(new HiddenStatusProxy
+ {
+ success = 1,
+ category = 5,
+ detectedBy = 3,
+ detail = 21,
+ });
+
+ Assert.Equal(compiledStruct, compiledClass);
+ Assert.Equal(compiledStruct, reflectedWide);
+ Assert.Equal(compiledStruct, reflectedHidden);
+ Assert.Equal(MxStatusCategory.OperationalError, compiledStruct.Category);
+ Assert.Equal(MxStatusSource.RespondingNmx, compiledStruct.DetectedBy);
+ Assert.Equal("Invalid reference", compiledStruct.DiagnosticText);
+ }
+
+ ///
+ /// Verifies the compiled accessor widens a negative 16-bit field the way
+ /// Convert.ToInt32 did — sign-extended, not reinterpreted. The
+ /// interop MXSTATUS_PROXY declares 16-bit fields, so a sign-extension
+ /// mistake here would silently change every failing status the gateway
+ /// reports.
+ ///
+ [Fact]
+ public void Convert_WithExtremeInt16FieldValues_SignExtendsLikeConvertToInt32()
+ {
+ FakeMxStatusProxy status = new()
+ {
+ success = short.MinValue,
+ category = 2,
+ detectedBy = 0,
+ detail = short.MaxValue,
+ };
+
+ MxStatusProxy converted = _converter.Convert(status);
+
+ Assert.Equal((int)short.MinValue, converted.Success);
+ Assert.Equal((int)short.MaxValue, converted.Detail);
+ Assert.Equal(MxStatusCategory.Warning, converted.Category);
+ Assert.Equal(MxStatusSource.RequestingLmx, converted.DetectedBy);
+ }
+
public struct FakeMxStatusProxy
{
public short success;
@@ -146,6 +230,39 @@ public sealed class MxStatusProxyConverterTests
public short detail;
}
+ public struct FakeWideStatusProxy
+ {
+ public long success;
+
+ public long category;
+
+ public long detectedBy;
+
+ public long detail;
+ }
+
+ public sealed class FakeStatusProxyClass
+ {
+ public int success;
+
+ public int category;
+
+ public int detectedBy;
+
+ public int detail;
+ }
+
+ private struct HiddenStatusProxy
+ {
+ public short success;
+
+ public int category;
+
+ public int detectedBy;
+
+ public short detail;
+ }
+
private sealed class MissingFields
{
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs
index 9e1c09d..fba52ec 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessEventMapperTests.cs
@@ -238,6 +238,97 @@ public sealed class MxAccessEventMapperTests
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _));
}
+ ///
+ /// Verifies the exact-format fast path PERF-20 added in front of the
+ /// general parse chain returns exactly what that chain returned. The
+ /// cases cover the captured MXAccess shape (which the fast path is meant
+ /// to catch on a US-shaped host), its zero-padded and second-precision
+ /// siblings, and strings the derived formats cannot match — an ISO
+ /// round-trip form, a long-date form, and a day-first European form —
+ /// which must fall through to the unchanged two-stage chain. The
+ /// expectation is computed with the pre-PERF-20 chain in this test, so it
+ /// holds on any host culture: the fast path is a shortcut, never a
+ /// different answer.
+ ///
+ /// Timestamp string to parse.
+ [Theory]
+ [InlineData("3/26/2026 1:38:22.907 PM")]
+ [InlineData("3/26/2026 1:38:22 PM")]
+ [InlineData("03/26/2026 01:38:22 PM")]
+ [InlineData("12/31/2026 11:59:59.999 PM")]
+ [InlineData("2026-03-26T13:38:22.9070000")]
+ [InlineData("Thursday, March 26, 2026 1:38:22 PM")]
+ [InlineData("26.03.2026 13:38:22")]
+ [InlineData("not a timestamp")]
+ public void TryParseSourceTimestamp_MatchesPreFastPathChain(string text)
+ {
+ bool expectedParsed = TryParseWithGeneralChainOnly(text, out DateTime expectedUtc);
+
+ bool parsed = MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc);
+
+ Assert.Equal(expectedParsed, parsed);
+ Assert.Equal(expectedUtc, utc);
+ if (parsed)
+ {
+ Assert.Equal(DateTimeKind.Utc, utc.Kind);
+ }
+ }
+
+ ///
+ /// Verifies a timestamp written in the host culture's own patterns —
+ /// including the millisecond fraction MXAccess appends, which no culture
+ /// publishes in its long-time pattern — parses as local wall-clock time
+ /// and comes back as UTC. This is the string shape the derived exact
+ /// formats are built for, so it exercises the fast path on the worker's
+ /// own host regardless of which culture that host runs.
+ ///
+ [Fact]
+ public void TryParseSourceTimestamp_WithCultureShapedMillisecondTimestamp_ReturnsLocalWallClockAsUtc()
+ {
+ CultureInfo culture = CultureInfo.CurrentCulture;
+ string longTime = culture.DateTimeFormat.LongTimePattern;
+ int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
+
+ // Every Windows culture publishes seconds in its long-time pattern; the
+ // guard keeps the test honest rather than green-by-accident if one does not.
+ Assert.True(secondsIndex >= 0, $"Long-time pattern '{longTime}' has no seconds specifier.");
+
+ DateTime localWall = new(2026, 3, 26, 13, 38, 22, 907, DateTimeKind.Unspecified);
+ string text = localWall.ToString(
+ culture.DateTimeFormat.ShortDatePattern + " " + longTime.Insert(secondsIndex + 2, ".fff"),
+ culture);
+
+ Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(text, out DateTime utc));
+ Assert.Equal(DateTimeKind.Utc, utc.Kind);
+ Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);
+ }
+
+ ///
+ /// The parse chain exactly as it stood before PERF-20 added the
+ /// exact-format stage, used as the parity oracle above.
+ ///
+ /// Timestamp string to parse.
+ /// The parsed UTC timestamp on success.
+ /// when the string parsed successfully.
+ private static bool TryParseWithGeneralChainOnly(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 sealed class FakeStatus
{
public int success;
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusProxyConverter.cs b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusProxyConverter.cs
index 036cd9c..9aad71f 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusProxyConverter.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/Conversion/MxStatusProxyConverter.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
+using System.Linq.Expressions;
using System.Reflection;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
@@ -11,13 +12,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
public sealed class MxStatusProxyConverter
{
///
- /// Per-type cache of the four resolved objects a
- /// status conversion needs. The status type is stable (the interop
- /// MXSTATUS_PROXY struct in production; a fixed test double in
- /// tests), so the expensive
- /// metadata scan is resolved once per type and reused. Keyed by
- /// so a plain-CLR test double and the real interop
- /// struct each get their own entry, keeping the converter interop-agnostic.
+ /// Per-type cache of the four field accessors a status conversion needs.
+ /// The status type is stable (the interop MXSTATUS_PROXY struct in
+ /// production; a fixed test double in tests), so the expensive
+ /// metadata scan — and,
+ /// for a visible type, the one-time expression compile that replaces
+ /// on the event path — happens
+ /// once per type and is reused. Keyed by so a
+ /// plain-CLR test double and the real interop struct each get their own
+ /// entry, keeping the converter interop-agnostic.
///
private static readonly ConcurrentDictionary FieldCache = new();
@@ -31,12 +34,11 @@ public sealed class MxStatusProxyConverter
throw new ArgumentNullException(nameof(status));
}
- Type statusType = status.GetType();
- StatusFields fields = GetFields(statusType);
- int success = ReadInt32Field(status, statusType, fields.Success);
- int rawCategory = ReadInt32Field(status, statusType, fields.Category);
- int rawDetectedBy = ReadInt32Field(status, statusType, fields.DetectedBy);
- int detail = ReadInt32Field(status, statusType, fields.Detail);
+ StatusFields fields = GetFields(status.GetType());
+ int success = fields.Success(status);
+ int rawCategory = fields.Category(status);
+ int rawDetectedBy = fields.DetectedBy(status);
+ int detail = fields.Detail(status);
return new MxStatusProxy
{
@@ -109,26 +111,102 @@ public sealed class MxStatusProxyConverter
}
///
- /// Resolves (and caches) the four objects for the
- /// given status type. The first resolution for a type performs the
- /// reflection scan; every subsequent conversion of that type reuses the
- /// cached entry. A type missing a required field throws the same
- /// the per-field lookup used to
- /// throw — and, because
+ /// Resolves (and caches) the four field accessors for the given status
+ /// type. The first resolution for a type performs the reflection scan and
+ /// the expression compile; every subsequent conversion of that type
+ /// reuses the cached delegates. A type missing a required field throws the
+ /// same the per-field lookup used
+ /// to throw — and, because
/// does not store a value when the factory throws, a bad type keeps
/// failing identically on every call rather than being cached.
///
/// Runtime type of the status object being converted.
- /// The resolved field set for .
+ /// The resolved field accessors for .
private static StatusFields GetFields(Type statusType)
{
return FieldCache.GetOrAdd(
statusType,
type => new StatusFields(
- ResolveField(type, "success"),
- ResolveField(type, "category"),
- ResolveField(type, "detectedBy"),
- ResolveField(type, "detail")));
+ BuildAccessor(type, "success"),
+ BuildAccessor(type, "category"),
+ BuildAccessor(type, "detectedBy"),
+ BuildAccessor(type, "detail")));
+ }
+
+ ///
+ /// Builds the accessor for one status field. Every OnDataChange carries a
+ /// status array, so this read is on the hot event path: a public field on
+ /// a publicly visible type is read through a delegate compiled once from
+ /// an expression tree, which drops the boxed that
+ /// allocates per field per status
+ /// entry, plus the dispatch behind
+ /// .
+ ///
+ /// The value is required to be identical, so the compiled path is
+ /// taken only for field types whose conversion to
+ /// is a lossless widening (see ) —
+ /// the interop MXSTATUS_PROXY declares its four fields as
+ /// 16/32-bit integers. Field types where the CLR conversion and
+ /// Convert.ToInt32 can disagree (bool, char, floating point,
+ /// unsigned 32/64-bit, or a reference type that could be null) keep
+ /// the reflection read verbatim, as does a non-visible type, whose
+ /// members a compiled delegate is not permitted to touch.
+ ///
+ ///
+ /// Runtime type of the status object.
+ /// Name of the status field to read.
+ /// A delegate reading the field as an .
+ private static Func