f61c816acf
WRK-06: MxStatusProxyConverter caches the four resolved FieldInfo per status type in a static ConcurrentDictionary (the GetField metadata scan ran 4x per status per event on the STA path). GetValue+Convert.ToInt32 still run per event (late-bound RCW). Exceptions byte-identical: missing-field message unchanged (ResolveField, not cached on throw via GetOrAdd); null-value message unchanged. WRK-11: MxAccessEventQueue.Enqueue takes ownership of the passed MxEvent - stamps WorkerSequence/WorkerTimestamp on it in place and enqueues it, no Clone(). Audited all 3 callers (base/alarm event sinks, provider-mode handler): each builds a fresh event per Enqueue, none reuse it. MxAccessValueCache.Set now deep-copies its retained Value/SourceTimestamp/Statuses so the cache snapshot never aliases the queue-owned (later serialized) event. Net: alarm/other events clone nothing (was full clone); data-change clones payload-only. WRK-12: WorkerFrameWriter coalesces the flush across a drained batch - each frame is written but not flushed individually; one FlushAsync after the batch, then all written frames complete. Preserves the written+flushed completion contract; a burst of N events costs 1 flush, not N. On write failure the whole in-flight batch + queue fail so no caller hangs. IPC-15 (doc): the multi-event WorkerEnvelope body remains unimplemented (wire still carries one event per worker_event frame); gateway.md Performance section now distinguishes the shipped flush-coalescing from that deferred proto change. net48-safe (no init/records; readonly struct cache entry). Worker builds x86 only - verification on windev. Tests added: converter cache-reuse, queue ownership-transfer, value-cache snapshot independence, writer batch-flush-once. Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
209 lines
8.7 KiB
C#
209 lines
8.7 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="MxAccessValueCache"/>. The cache is consumed by
|
|
/// <see cref="MxAccessSession.ReadBulk"/> to satisfy "current value"
|
|
/// requests for already-advised tags without touching the existing
|
|
/// subscription, so its contract is exercised in isolation here before any
|
|
/// STA / COM plumbing gets layered on top.
|
|
/// </summary>
|
|
public sealed class MxAccessValueCacheTests
|
|
{
|
|
/// <summary>Verifies that cache returns the last value with incrementing versions.</summary>
|
|
[Fact]
|
|
public void Set_ThenTryGet_ReturnsLastValueWithIncrementingVersion()
|
|
{
|
|
MxAccessValueCache cache = new();
|
|
Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc));
|
|
|
|
cache.Set(serverHandle: 7, itemHandle: 21, BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp));
|
|
|
|
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue first));
|
|
Assert.Equal(1UL, first.Version);
|
|
Assert.Equal(100, first.Value.Int32Value);
|
|
Assert.Equal(192, first.Quality);
|
|
Assert.Equal(sourceTimestamp, first.SourceTimestamp);
|
|
|
|
// A second Set on the same key bumps the version and overwrites the
|
|
// payload. Different keys remain isolated.
|
|
cache.Set(7, 21, BuildEvent(7, 21, intValue: 200, quality: 192, sourceTimestamp));
|
|
cache.Set(7, 22, BuildEvent(7, 22, intValue: 999, quality: 192, sourceTimestamp));
|
|
|
|
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue second));
|
|
Assert.Equal(2UL, second.Version);
|
|
Assert.Equal(200, second.Value.Int32Value);
|
|
|
|
Assert.True(cache.TryGet(7, 22, out MxAccessValueCache.CachedValue other));
|
|
Assert.Equal(1UL, other.Version);
|
|
Assert.Equal(999, other.Value.Int32Value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that Set stores an independent deep-copied snapshot: mutating
|
|
/// the source event's protobuf sub-messages after caching does not alter
|
|
/// the cached value. WRK-11 stopped the event sink cloning before enqueue,
|
|
/// so the same MxEvent instance now flows to the outbound queue; the cache
|
|
/// must own its own copy so the two never share mutable state.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Set_StoresIndependentSnapshot_UnaffectedByLaterEventMutation()
|
|
{
|
|
MxAccessValueCache cache = new();
|
|
Timestamp sourceTimestamp = Timestamp.FromDateTime(new(2026, 5, 19, 9, 0, 0, DateTimeKind.Utc));
|
|
MxEvent mxEvent = BuildEvent(serverHandle: 7, itemHandle: 21, intValue: 100, quality: 192, sourceTimestamp);
|
|
|
|
cache.Set(7, 21, mxEvent);
|
|
|
|
// Mutate the event in place after it was cached — as if it kept flowing
|
|
// through the (unrelated) outbound path. None of this must reach the cache.
|
|
mxEvent.Value.Int32Value = 999;
|
|
mxEvent.Quality = 0;
|
|
mxEvent.SourceTimestamp = Timestamp.FromDateTime(new(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
|
mxEvent.Statuses[0].Category = MxStatusCategory.SecurityError;
|
|
|
|
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue cached));
|
|
Assert.Equal(100, cached.Value.Int32Value);
|
|
Assert.Equal(192, cached.Quality);
|
|
Assert.Equal(sourceTimestamp, cached.SourceTimestamp);
|
|
Assert.Single(cached.Statuses);
|
|
Assert.Equal(MxStatusCategory.Ok, cached.Statuses[0].Category);
|
|
}
|
|
|
|
/// <summary>Verifies that TryGet returns false for unknown handles.</summary>
|
|
[Fact]
|
|
public void TryGet_WithUnknownHandle_ReturnsFalse()
|
|
{
|
|
MxAccessValueCache cache = new();
|
|
|
|
Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _));
|
|
}
|
|
|
|
/// <summary>Verifies that Remove drops entries and resets versions.</summary>
|
|
[Fact]
|
|
public void Remove_DropsEntryAndResetsVersion()
|
|
{
|
|
MxAccessValueCache cache = new();
|
|
cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
|
|
cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
|
|
|
|
cache.Remove(7, 21);
|
|
Assert.False(cache.TryGet(7, 21, out _));
|
|
|
|
// After Remove, a subsequent Set restarts the per-handle version from 1
|
|
// — the cache must not serve a stale "version 3" entry that would race
|
|
// against a reused MXAccess item handle.
|
|
cache.Set(7, 21, BuildEvent(7, 21, intValue: 3, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
|
|
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue reset));
|
|
Assert.Equal(1UL, reset.Version);
|
|
}
|
|
|
|
/// <summary>Verifies that CurrentVersion returns zero for unknown handles and the latest for known ones.</summary>
|
|
[Fact]
|
|
public void CurrentVersion_ReturnsZeroForUnknown_AndLatestForKnown()
|
|
{
|
|
MxAccessValueCache cache = new();
|
|
Assert.Equal(0UL, cache.CurrentVersion(7, 21));
|
|
|
|
cache.Set(7, 21, BuildEvent(7, 21, intValue: 1, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
|
|
cache.Set(7, 21, BuildEvent(7, 21, intValue: 2, quality: 192, Timestamp.FromDateTime(DateTime.UtcNow)));
|
|
|
|
Assert.Equal(2UL, cache.CurrentVersion(7, 21));
|
|
}
|
|
|
|
/// <summary>Verifies that TryWaitForUpdate returns false after the deadline expires.</summary>
|
|
[Fact]
|
|
public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs()
|
|
{
|
|
MxAccessValueCache cache = new();
|
|
int pumpCalls = 0;
|
|
|
|
// Deadline already in the past — eliminates the wall-clock-floor
|
|
// race. The loop must pump once (so MXAccess messages can dispatch
|
|
// on the calling thread even when the deadline has just expired)
|
|
// and then immediately observe the passed deadline.
|
|
DateTime expiredDeadlineUtc = DateTime.UtcNow.AddMilliseconds(-1);
|
|
|
|
bool result = cache.TryWaitForUpdate(
|
|
serverHandle: 7,
|
|
itemHandle: 21,
|
|
sinceVersion: 0,
|
|
deadlineUtc: expiredDeadlineUtc,
|
|
pumpStep: () => Interlocked.Increment(ref pumpCalls),
|
|
out MxAccessValueCache.CachedValue value,
|
|
pollIntervalMs: 5);
|
|
|
|
Assert.False(result);
|
|
Assert.Equal(default, value.Value);
|
|
Assert.Equal(1, pumpCalls);
|
|
}
|
|
|
|
/// <summary>Verifies that TryWaitForUpdate returns true when the cache is updated after the baseline.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task TryWaitForUpdate_ReturnsTrue_WhenSetFiresAfterBaselineVersion()
|
|
{
|
|
MxAccessValueCache cache = new();
|
|
Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
|
|
// Baseline is "no entry yet" → wait for the first Set to land.
|
|
Task<(bool ok, MxAccessValueCache.CachedValue value)> waitTask = Task.Run(() =>
|
|
{
|
|
bool ok = cache.TryWaitForUpdate(
|
|
serverHandle: 7,
|
|
itemHandle: 21,
|
|
sinceVersion: 0,
|
|
deadlineUtc: DateTime.UtcNow.AddSeconds(2),
|
|
pumpStep: () => { },
|
|
out MxAccessValueCache.CachedValue v,
|
|
pollIntervalMs: 5);
|
|
return (ok, v);
|
|
});
|
|
|
|
// Race a Set against the wait loop. The cache's lock guarantees the
|
|
// wait observes the new version before TryGet returns it.
|
|
await Task.Delay(20);
|
|
cache.Set(7, 21, BuildEvent(7, 21, intValue: 4242, quality: 192, sourceTimestamp));
|
|
|
|
(bool ok, MxAccessValueCache.CachedValue value) = await waitTask;
|
|
Assert.True(ok);
|
|
Assert.Equal(4242, value.Value.Int32Value);
|
|
Assert.Equal(1UL, value.Version);
|
|
}
|
|
|
|
private static MxEvent BuildEvent(
|
|
int serverHandle,
|
|
int itemHandle,
|
|
int intValue,
|
|
int quality,
|
|
Timestamp sourceTimestamp)
|
|
{
|
|
MxEvent mxEvent = new()
|
|
{
|
|
Family = MxEventFamily.OnDataChange,
|
|
ServerHandle = serverHandle,
|
|
ItemHandle = itemHandle,
|
|
Quality = quality,
|
|
SourceTimestamp = sourceTimestamp,
|
|
Value = new MxValue
|
|
{
|
|
DataType = MxDataType.Integer,
|
|
VariantType = "VT_I4",
|
|
Int32Value = intValue,
|
|
},
|
|
OnDataChange = new OnDataChangeEvent(),
|
|
};
|
|
mxEvent.Statuses.Add(new MxStatusProxy
|
|
{
|
|
Category = MxStatusCategory.Ok,
|
|
});
|
|
return mxEvent;
|
|
}
|
|
}
|