perf(worker): value cache borrows the write-once event's instances — three clones per OnDataChange removed
MxAccessValueCache.Set deep-copied the Value (recursive for an MxArray), the SourceTimestamp, and the Statuses RepeatedField (container plus every MxStatusProxy) on every OnDataChange. The aliasing audit found all three removable: the sink enqueues the event first — which stamps WorkerSequence/WorkerTimestamp inside the queue lock — and only then runs the postPublish hook that reaches Set, so the event is write-once by then and the queue's ownership invariant forbids later mutation. The producer never reuses instances (fresh MxEvent per mapper call, fresh MxValue per convert), and the alias already existed on the read side: MxAccessSession.SucceededRead puts the cache's own Value/SourceTimestamp/status references on every BulkReadResult, which the worker only serializes onto the IPC pipe. Set and CachedValue now carry the ownership contract: the cache holds borrowed references into an enqueued, write-once MxEvent; consumers may read and serialize, never mutate. Mutation would corrupt the still-queued event AND invalidate QueuedEvent.Size — the enqueue-time memoized serialized size the byte-budgeted Drain charges — so a grown message could overshoot the negotiated frame max and fault the session with MessageTooLarge. MxAccessEventQueue's class remark, which claimed the cache keeps an independent snapshot, is corrected to point at the borrow. MxAccessWriteCompletionCache.Record keeps its parallel statuses.Clone() deliberately, with a cross-reference explaining why: it takes a bare RepeatedField whose provenance its signature cannot constrain, and it is on the command-rate write path, not the streaming hot path. Tests: Set_StoresIndependentSnapshot_UnaffectedByLaterEventMutation codified the invariant being reversed, so it is replaced by Set_BorrowsTheEventsOwnInstances_ByOwnershipContract (Assert.Same on Value, SourceTimestamp, and the status row). Adds the missing cached-read-path test to MxAccessCommandExecutorTests — nothing in the worker exercised was_cached == true end to end — asserting the cache hit, reference identity out to the BulkReadResult, and that no COM call is made for the read. Not built or tested here: these are net48/x86 worker files that cannot compile on the macOS tree. Verification is deferred to the windev gate.
This commit is contained in:
@@ -669,6 +669,107 @@ public sealed class MxAccessCommandExecutorTests
|
||||
Assert.Contains("RemoveItem:90:900", fakeComObject.OperationNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies ReadBulk's cached fast path end to end — the
|
||||
/// <c>was_cached = true</c> half of the command, which nothing else in
|
||||
/// the worker suite exercises. With the tag already added AND advised
|
||||
/// and a value in the per-session cache, the executor answers from the
|
||||
/// cache: the result is successful and flagged cached, and no COM call
|
||||
/// is made for the read at all, so the subscription the caller did not
|
||||
/// create is left exactly as it was.
|
||||
///
|
||||
/// It also pins the borrow contract through the whole read path: the
|
||||
/// <c>Value</c>, <c>SourceTimestamp</c>, and status row on the
|
||||
/// <c>BulkReadResult</c> are the cached event's own instances, not
|
||||
/// copies (see <see cref="MxAccessValueCache.Set"/>). The worker only
|
||||
/// serializes them onto the IPC pipe, so the alias never escapes the
|
||||
/// process.
|
||||
///
|
||||
/// Driven through <see cref="MxAccessCommandExecutor"/> directly rather
|
||||
/// than <see cref="MxAccessStaSession"/> because seeding the cache needs
|
||||
/// a handle on it: <see cref="MxAccessSession.Create"/> only shares the
|
||||
/// sink's cache for the production <c>MxAccessBaseEventSink</c>, which
|
||||
/// casts the COM object to <c>LMXProxyServerClass</c> and so cannot take
|
||||
/// a fake, while <c>CreateForTesting</c> accepts the cache directly. The
|
||||
/// cached path neither waits nor pumps, so it needs no STA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Execute_ReadBulk_WhenTagIsAdvisedAndCached_ServesTheCachedInstancesWithoutTouchingTheSubscription()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 92,
|
||||
addItemHandle: 920);
|
||||
MxAccessValueCache valueCache = new();
|
||||
using MxAccessSession session = MxAccessSession.CreateForTesting(
|
||||
mxAccessServer: fakeComObject,
|
||||
eventSink: new NoopEventSink(),
|
||||
valueCache: valueCache);
|
||||
MxAccessCommandExecutor executor = new(
|
||||
session,
|
||||
new ZB.MOM.WW.MxGateway.Worker.Conversion.VariantConverter());
|
||||
|
||||
// Registry half of the fast path: the tag must resolve to a live item
|
||||
// handle on this server AND carry an advice, or TryGetCachedReadFor
|
||||
// falls through to the AddItem/Advise snapshot lifecycle.
|
||||
MxCommandReply registerReply = executor.Execute(
|
||||
CreateRegisterCommand("register-before-cached-read", "client-a"));
|
||||
MxCommandReply addItemReply = executor.Execute(
|
||||
CreateAddItemCommand("add-before-cached-read", 92, "Galaxy.Tag.Value"));
|
||||
MxCommandReply adviseReply = executor.Execute(
|
||||
CreateAdviseCommand("advise-before-cached-read", 92, 920));
|
||||
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
|
||||
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
|
||||
|
||||
// Cache half: stand in for the event sink's post-publish hook, which
|
||||
// records the event it just enqueued.
|
||||
MxEvent cachedEvent = new()
|
||||
{
|
||||
Family = MxEventFamily.OnDataChange,
|
||||
ServerHandle = 92,
|
||||
ItemHandle = 920,
|
||||
Quality = 192,
|
||||
SourceTimestamp = Timestamp.FromDateTime(new(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc)),
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Integer,
|
||||
VariantType = "VT_I4",
|
||||
Int32Value = 7788,
|
||||
},
|
||||
OnDataChange = new OnDataChangeEvent(),
|
||||
};
|
||||
cachedEvent.Statuses.Add(new MxStatusProxy { Category = MxStatusCategory.Ok });
|
||||
valueCache.Set(92, 920, cachedEvent);
|
||||
|
||||
MxCommandReply reply = executor.Execute(CreateReadBulkCommand(
|
||||
"read-bulk-cached",
|
||||
serverHandle: 92,
|
||||
tagAddresses: new[] { "Galaxy.Tag.Value" },
|
||||
timeoutMs: 80));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(MxCommandKind.ReadBulk, reply.Kind);
|
||||
BulkReadResult result = Assert.Single(reply.ReadBulk.Results);
|
||||
Assert.True(result.WasSuccessful);
|
||||
Assert.True(result.WasCached);
|
||||
Assert.Equal("Galaxy.Tag.Value", result.TagAddress);
|
||||
Assert.Equal(920, result.ItemHandle);
|
||||
Assert.Equal(192, result.Quality);
|
||||
Assert.Equal(7788, result.Value.Int32Value);
|
||||
|
||||
// Borrowed, not copied — all the way from the event handed to
|
||||
// MxAccessValueCache.Set out to the reply the worker serializes.
|
||||
Assert.Same(cachedEvent.Value, result.Value);
|
||||
Assert.Same(cachedEvent.SourceTimestamp, result.SourceTimestamp);
|
||||
Assert.Same(cachedEvent.Statuses[0], Assert.Single(result.Statuses));
|
||||
|
||||
// No second AddItem, and above all no UnAdvise/RemoveItem: only the
|
||||
// three setup calls ever reached MXAccess.
|
||||
Assert.Equal(
|
||||
new[] { "Register:client-a", "AddItem:92:Galaxy.Tag.Value", "Advise:92:920" },
|
||||
fakeComObject.OperationNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadBulk with no payload returns an invalid request error.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user