perf(worker): exact-format timestamp parse and compiled status-field accessors on the event path

This commit is contained in:
Joseph Doherty
2026-08-15 16:59:52 -04:00
parent f4a6cb1db2
commit 94fdc18c3c
4 changed files with 386 additions and 36 deletions
@@ -135,6 +135,90 @@ public sealed class MxStatusProxyConverterTests
Assert.Equal("Invalid reference", second.DiagnosticText);
}
/// <summary>
/// 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
/// <c>FieldInfo.GetValue</c>; a field type whose conversion to int is not
/// a lossless widening (<see cref="FakeWideStatusProxy"/>) and a type the
/// compiled delegate may not touch (<see cref="HiddenStatusProxy"/>, 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.
/// </summary>
[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);
}
/// <summary>
/// Verifies the compiled accessor widens a negative 16-bit field the way
/// <c>Convert.ToInt32</c> 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.
/// </summary>
[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
{
}
@@ -238,6 +238,97 @@ public sealed class MxAccessEventMapperTests
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _));
}
/// <summary>
/// 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.
/// </summary>
/// <param name="text">Timestamp string to parse.</param>
[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);
}
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// The parse chain exactly as it stood before PERF-20 added the
/// exact-format stage, used as the parity oracle above.
/// </summary>
/// <param name="text">Timestamp string to parse.</param>
/// <param name="utc">The parsed UTC timestamp on success.</param>
/// <returns><see langword="true"/> when the string parsed successfully.</returns>
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;
@@ -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
{
/// <summary>
/// Per-type cache of the four resolved <see cref="FieldInfo"/> objects a
/// status conversion needs. The status type is stable (the interop
/// <c>MXSTATUS_PROXY</c> struct in production; a fixed test double in
/// tests), so the expensive <see cref="Type.GetField(string, BindingFlags)"/>
/// metadata scan is resolved once per type and reused. Keyed by
/// <see cref="Type"/> 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 <c>MXSTATUS_PROXY</c> struct in
/// production; a fixed test double in tests), so the expensive
/// <see cref="Type.GetField(string, BindingFlags)"/> metadata scan — and,
/// for a visible type, the one-time expression compile that replaces
/// <see cref="FieldInfo.GetValue(object)"/> on the event path — happens
/// once per type and is reused. Keyed by <see cref="Type"/> so a
/// plain-CLR test double and the real interop struct each get their own
/// entry, keeping the converter interop-agnostic.
/// </summary>
private static readonly ConcurrentDictionary<Type, StatusFields> 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
}
/// <summary>
/// Resolves (and caches) the four <see cref="FieldInfo"/> 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
/// <see cref="MxStatusConversionException"/> the per-field lookup used to
/// throw — and, because <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/>
/// 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 <see cref="MxStatusConversionException"/> the per-field lookup used
/// to throw — and, because <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/>
/// does not store a value when the factory throws, a bad type keeps
/// failing identically on every call rather than being cached.
/// </summary>
/// <param name="statusType">Runtime type of the status object being converted.</param>
/// <returns>The resolved field set for <paramref name="statusType"/>.</returns>
/// <returns>The resolved field accessors for <paramref name="statusType"/>.</returns>
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")));
}
/// <summary>
/// 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 <see cref="object"/> that
/// <see cref="FieldInfo.GetValue(object)"/> allocates per field per status
/// entry, plus the <see cref="IConvertible"/> dispatch behind
/// <see cref="System.Convert.ToInt32(object, IFormatProvider)"/>.
/// <para>
/// The value is required to be identical, so the compiled path is
/// taken only for field types whose conversion to <see cref="int"/>
/// is a lossless widening (see <see cref="IsWideningToInt32"/>) —
/// the interop <c>MXSTATUS_PROXY</c> declares its four fields as
/// 16/32-bit integers. Field types where the CLR conversion and
/// <c>Convert.ToInt32</c> 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.
/// </para>
/// </summary>
/// <param name="valueType">Runtime type of the status object.</param>
/// <param name="fieldName">Name of the status field to read.</param>
/// <returns>A delegate reading the field as an <see cref="int"/>.</returns>
private static Func<object, int> BuildAccessor(
Type valueType,
string fieldName)
{
FieldInfo field = ResolveField(valueType, fieldName);
if (valueType.IsVisible && IsWideningToInt32(field.FieldType))
{
try
{
ParameterExpression parameter = Expression.Parameter(typeof(object), "status");
// Unbox (not Convert) for a struct: it addresses the boxed
// instance in place rather than copying it out before the load.
Expression instance = valueType.IsValueType
? Expression.Unbox(parameter, valueType)
: Expression.Convert(parameter, valueType);
Expression widened = Expression.Convert(Expression.Field(instance, field), typeof(int));
return Expression.Lambda<Func<object, int>>(widened, parameter).Compile();
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Compiling the accessor is an optimization, never a requirement:
// if the runtime refuses it the reflection read below still
// produces the same value.
}
}
return status => ReadInt32Field(status, valueType, field);
}
/// <summary>
/// Reports whether reading a field of this type as an <see cref="int"/>
/// via the CLR's own conversion is lossless and total, and therefore
/// indistinguishable from <see cref="System.Convert.ToInt32(object, IFormatProvider)"/>.
/// Enums answer with their underlying type code, which is the intent.
/// </summary>
/// <param name="fieldType">Declared type of the status field.</param>
/// <returns><see langword="true"/> when the compiled accessor is safe to use.</returns>
private static bool IsWideningToInt32(Type fieldType)
{
switch (Type.GetTypeCode(fieldType))
{
case TypeCode.SByte:
case TypeCode.Byte:
case TypeCode.Int16:
case TypeCode.UInt16:
case TypeCode.Int32:
return true;
default:
return false;
}
}
private static FieldInfo ResolveField(
@@ -179,17 +257,17 @@ public sealed class MxStatusProxyConverter
}
/// <summary>
/// The four resolved status fields cached per type. Plain readonly struct
/// (not a record) so it compiles under the worker's net48 target, which
/// lacks <c>IsExternalInit</c>.
/// The four resolved status-field accessors cached per type. Plain
/// readonly struct (not a record) so it compiles under the worker's net48
/// target, which lacks <c>IsExternalInit</c>.
/// </summary>
private readonly struct StatusFields
{
public StatusFields(
FieldInfo success,
FieldInfo category,
FieldInfo detectedBy,
FieldInfo detail)
Func<object, int> success,
Func<object, int> category,
Func<object, int> detectedBy,
Func<object, int> detail)
{
Success = success;
Category = category;
@@ -197,12 +275,12 @@ public sealed class MxStatusProxyConverter
Detail = detail;
}
public FieldInfo Success { get; }
public Func<object, int> Success { get; }
public FieldInfo Category { get; }
public Func<object, int> Category { get; }
public FieldInfo DetectedBy { get; }
public Func<object, int> DetectedBy { get; }
public FieldInfo Detail { get; }
public Func<object, int> Detail { get; }
}
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Globalization;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
@@ -9,6 +10,18 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
/// <summary>Maps MXAccess COM events to protobuf MxEvent messages.</summary>
public sealed class MxAccessEventMapper
{
/// <summary>
/// Per-culture cache of the explicit timestamp formats
/// <see cref="TryParseSourceTimestamp"/> tries before the general parse
/// chain. Keyed by <see cref="CultureInfo"/> because the format set is
/// derived from the culture's own patterns and the worker host's culture
/// is fixed in practice, so the derivation runs once.
/// </summary>
private static readonly ConcurrentDictionary<CultureInfo, string[]> TimestampFormatCache = new();
/// <summary>Cached factory delegate so the dictionary lookup allocates nothing on the event path.</summary>
private static readonly Func<CultureInfo, string[]> TimestampFormatFactory = BuildTimestampFormats;
private readonly VariantConverter variantConverter;
private readonly MxStatusProxyConverter statusProxyConverter;
@@ -353,6 +366,23 @@ public sealed class MxAccessEventMapper
/// 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.
/// <para>
/// PERF: every OnDataChange carries one of these strings, and the
/// general <see cref="DateTime.TryParse(string, IFormatProvider, DateTimeStyles, out DateTime)"/>
/// has to walk the culture's whole pattern set to recognize it. The
/// shapes MXAccess actually emits are tried first with
/// <see cref="DateTime.TryParseExact(string, string[], IFormatProvider, DateTimeStyles, out DateTime)"/>.
/// The formats are <em>derived from the current culture's own</em>
/// short-date and long-time patterns (see
/// <see cref="BuildTimestampFormats"/>) rather than hard-coded to the
/// US shapes seen in captures: a hard-coded <c>M/d/yyyy</c> would
/// reorder month and day on a day-first host relative to what the
/// general parse below returns. Deriving them means any string the
/// exact parse accepts is one the culture-aware general parse would
/// have accepted with the same value, so the fast path is a shortcut
/// and never a different answer; anything it does not match falls
/// through to the unchanged two-stage chain.
/// </para>
/// </summary>
/// <param name="text">The MXAccess timestamp string.</param>
/// <param name="utc">The parsed UTC timestamp on success.</param>
@@ -366,7 +396,10 @@ public sealed class MxAccessEventMapper
}
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed)
CultureInfo culture = CultureInfo.CurrentCulture;
string[] formats = TimestampFormatCache.GetOrAdd(culture, TimestampFormatFactory);
if (DateTime.TryParseExact(text, formats, culture, styles, out DateTime parsed)
|| DateTime.TryParse(text, culture, styles, out parsed)
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
{
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
@@ -376,6 +409,37 @@ public sealed class MxAccessEventMapper
return false;
}
/// <summary>
/// Builds the explicit timestamp formats for a culture: the culture's
/// short date plus its long time, and — first, because it is the shape
/// MXAccess actually fires — the same pattern with the millisecond
/// fraction MXAccess appends to the seconds ("3/26/2026 1:38:22.907 PM"
/// under en-US). No culture publishes a long-time pattern with
/// milliseconds, so the fractional sibling is derived by inserting
/// <c>.fff</c> after the seconds specifier; a pattern without a
/// two-digit seconds specifier, or one that already carries a fraction,
/// gets only the published pattern.
/// </summary>
/// <param name="culture">Culture whose patterns the formats are derived from.</param>
/// <returns>The formats to try, most likely first.</returns>
private static string[] BuildTimestampFormats(CultureInfo culture)
{
DateTimeFormatInfo formatInfo = culture.DateTimeFormat;
string shortDate = formatInfo.ShortDatePattern;
string longTime = formatInfo.LongTimePattern;
int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
if (secondsIndex >= 0 && longTime.IndexOf('f') < 0 && longTime.IndexOf('F') < 0)
{
return
[
shortDate + " " + longTime.Insert(secondsIndex + 2, ".fff"),
shortDate + " " + longTime,
];
}
return [shortDate + " " + longTime];
}
private MxArray ConvertBufferedArray(
object? value,
MxDataType expectedElementDataType)