perf(worker): exact-format timestamp parse and compiled status-field accessors on the event path
This commit is contained in:
@@ -135,6 +135,90 @@ public sealed class MxStatusProxyConverterTests
|
|||||||
Assert.Equal("Invalid reference", second.DiagnosticText);
|
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 struct FakeMxStatusProxy
|
||||||
{
|
{
|
||||||
public short success;
|
public short success;
|
||||||
@@ -146,6 +230,39 @@ public sealed class MxStatusProxyConverterTests
|
|||||||
public short detail;
|
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
|
private sealed class MissingFields
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,97 @@ public sealed class MxAccessEventMapperTests
|
|||||||
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp(text, out _));
|
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
|
private sealed class FakeStatus
|
||||||
{
|
{
|
||||||
public int success;
|
public int success;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
@@ -11,13 +12,15 @@ namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
|
|||||||
public sealed class MxStatusProxyConverter
|
public sealed class MxStatusProxyConverter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-type cache of the four resolved <see cref="FieldInfo"/> objects a
|
/// Per-type cache of the four field accessors a status conversion needs.
|
||||||
/// status conversion needs. The status type is stable (the interop
|
/// The status type is stable (the interop <c>MXSTATUS_PROXY</c> struct in
|
||||||
/// <c>MXSTATUS_PROXY</c> struct in production; a fixed test double in
|
/// production; a fixed test double in tests), so the expensive
|
||||||
/// tests), so the expensive <see cref="Type.GetField(string, BindingFlags)"/>
|
/// <see cref="Type.GetField(string, BindingFlags)"/> metadata scan — and,
|
||||||
/// metadata scan is resolved once per type and reused. Keyed by
|
/// for a visible type, the one-time expression compile that replaces
|
||||||
/// <see cref="Type"/> so a plain-CLR test double and the real interop
|
/// <see cref="FieldInfo.GetValue(object)"/> on the event path — happens
|
||||||
/// struct each get their own entry, keeping the converter interop-agnostic.
|
/// 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>
|
/// </summary>
|
||||||
private static readonly ConcurrentDictionary<Type, StatusFields> FieldCache = new();
|
private static readonly ConcurrentDictionary<Type, StatusFields> FieldCache = new();
|
||||||
|
|
||||||
@@ -31,12 +34,11 @@ public sealed class MxStatusProxyConverter
|
|||||||
throw new ArgumentNullException(nameof(status));
|
throw new ArgumentNullException(nameof(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
Type statusType = status.GetType();
|
StatusFields fields = GetFields(status.GetType());
|
||||||
StatusFields fields = GetFields(statusType);
|
int success = fields.Success(status);
|
||||||
int success = ReadInt32Field(status, statusType, fields.Success);
|
int rawCategory = fields.Category(status);
|
||||||
int rawCategory = ReadInt32Field(status, statusType, fields.Category);
|
int rawDetectedBy = fields.DetectedBy(status);
|
||||||
int rawDetectedBy = ReadInt32Field(status, statusType, fields.DetectedBy);
|
int detail = fields.Detail(status);
|
||||||
int detail = ReadInt32Field(status, statusType, fields.Detail);
|
|
||||||
|
|
||||||
return new MxStatusProxy
|
return new MxStatusProxy
|
||||||
{
|
{
|
||||||
@@ -109,26 +111,102 @@ public sealed class MxStatusProxyConverter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves (and caches) the four <see cref="FieldInfo"/> objects for the
|
/// Resolves (and caches) the four field accessors for the given status
|
||||||
/// given status type. The first resolution for a type performs the
|
/// type. The first resolution for a type performs the reflection scan and
|
||||||
/// reflection scan; every subsequent conversion of that type reuses the
|
/// the expression compile; every subsequent conversion of that type
|
||||||
/// cached entry. A type missing a required field throws the same
|
/// reuses the cached delegates. A type missing a required field throws the
|
||||||
/// <see cref="MxStatusConversionException"/> the per-field lookup used to
|
/// same <see cref="MxStatusConversionException"/> the per-field lookup used
|
||||||
/// throw — and, because <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey, Func{TKey, TValue})"/>
|
/// 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
|
/// does not store a value when the factory throws, a bad type keeps
|
||||||
/// failing identically on every call rather than being cached.
|
/// failing identically on every call rather than being cached.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="statusType">Runtime type of the status object being converted.</param>
|
/// <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)
|
private static StatusFields GetFields(Type statusType)
|
||||||
{
|
{
|
||||||
return FieldCache.GetOrAdd(
|
return FieldCache.GetOrAdd(
|
||||||
statusType,
|
statusType,
|
||||||
type => new StatusFields(
|
type => new StatusFields(
|
||||||
ResolveField(type, "success"),
|
BuildAccessor(type, "success"),
|
||||||
ResolveField(type, "category"),
|
BuildAccessor(type, "category"),
|
||||||
ResolveField(type, "detectedBy"),
|
BuildAccessor(type, "detectedBy"),
|
||||||
ResolveField(type, "detail")));
|
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(
|
private static FieldInfo ResolveField(
|
||||||
@@ -179,17 +257,17 @@ public sealed class MxStatusProxyConverter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The four resolved status fields cached per type. Plain readonly struct
|
/// The four resolved status-field accessors cached per type. Plain
|
||||||
/// (not a record) so it compiles under the worker's net48 target, which
|
/// readonly struct (not a record) so it compiles under the worker's net48
|
||||||
/// lacks <c>IsExternalInit</c>.
|
/// target, which lacks <c>IsExternalInit</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly struct StatusFields
|
private readonly struct StatusFields
|
||||||
{
|
{
|
||||||
public StatusFields(
|
public StatusFields(
|
||||||
FieldInfo success,
|
Func<object, int> success,
|
||||||
FieldInfo category,
|
Func<object, int> category,
|
||||||
FieldInfo detectedBy,
|
Func<object, int> detectedBy,
|
||||||
FieldInfo detail)
|
Func<object, int> detail)
|
||||||
{
|
{
|
||||||
Success = success;
|
Success = success;
|
||||||
Category = category;
|
Category = category;
|
||||||
@@ -197,12 +275,12 @@ public sealed class MxStatusProxyConverter
|
|||||||
Detail = detail;
|
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;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
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>
|
/// <summary>Maps MXAccess COM events to protobuf MxEvent messages.</summary>
|
||||||
public sealed class MxAccessEventMapper
|
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 VariantConverter variantConverter;
|
||||||
private readonly MxStatusProxyConverter statusProxyConverter;
|
private readonly MxStatusProxyConverter statusProxyConverter;
|
||||||
|
|
||||||
@@ -353,6 +366,23 @@ public sealed class MxAccessEventMapper
|
|||||||
/// wall-clock UTC). The parsed value is therefore taken as local time
|
/// wall-clock UTC). The parsed value is therefore taken as local time
|
||||||
/// and converted to UTC. Tries the worker host's culture first
|
/// and converted to UTC. Tries the worker host's culture first
|
||||||
/// (MXAccess formats with the host locale), then the invariant culture.
|
/// (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>
|
/// </summary>
|
||||||
/// <param name="text">The MXAccess timestamp string.</param>
|
/// <param name="text">The MXAccess timestamp string.</param>
|
||||||
/// <param name="utc">The parsed UTC timestamp on success.</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;
|
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))
|
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
|
||||||
{
|
{
|
||||||
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||||
@@ -376,6 +409,37 @@ public sealed class MxAccessEventMapper
|
|||||||
return false;
|
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(
|
private MxArray ConvertBufferedArray(
|
||||||
object? value,
|
object? value,
|
||||||
MxDataType expectedElementDataType)
|
MxDataType expectedElementDataType)
|
||||||
|
|||||||
Reference in New Issue
Block a user