fix(GWC-29): drop the wasted request clone on the Invoke hot path

Invoke deep-cloned the whole MxCommandRequest — including its command
payload, potentially a large bulk-write graph — only to overwrite the
cloned command with commandToInvoke and discard it. MapCommand then did
the one clone actually needed. Net cost: a full wasted command deep-clone
per Invoke, worst for exactly the bulk writes that are largest.

Adds a MapCommand(MxCommand) overload (MapCommand reads nothing else off
the request) and has Invoke pass commandToInvoke directly; the request
overload delegates so other callers are untouched.

The remaining clone inside MapCommand stays and is now documented as
required rather than incidental: commandToInvoke may be the gRPC-owned
request.Command, and the caller reads it again after dispatch via
TrackCommandReply, so ownership transfer (à la GWC-07) is not safe here.
That clone is what keeps WorkerClient.CreateCommandEnvelope's no-aliasing
invariant true.

Tests: MxAccessGrpcMapperTests.MapCommandFromCommandClonesPayload
(mutating the input leaves the mapped command untouched; both overloads
produce equal results under a fixed TimeProvider).
This commit is contained in:
Joseph Doherty
2026-08-07 06:15:31 -04:00
parent f27eb28063
commit a044f92c5d
3 changed files with 68 additions and 4 deletions
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Grpc;
@@ -38,6 +39,48 @@ public sealed class MxAccessGrpcMapperTests
Assert.NotNull(workerCommand.EnqueueTimestamp);
}
/// <summary>
/// The command-only overload exists so <c>Invoke</c> does not deep-clone the whole request just to
/// overwrite and discard its command (GWC-29). It must still perform the one clone that keeps the
/// worker-bound graph unaliased from the caller-owned gRPC command, and must produce the same
/// <see cref="WorkerCommand"/> as the request overload.
/// </summary>
[Fact]
public void MapCommandFromCommandClonesPayload()
{
FakeTimeProvider timeProvider = new(new DateTimeOffset(2026, 8, 7, 12, 0, 0, TimeSpan.Zero));
MxAccessGrpcMapper mapper = new(timeProvider);
MxCommand command = new()
{
Kind = MxCommandKind.Write,
Write = new WriteCommand
{
ServerHandle = 10,
ItemHandle = 20,
UserId = 30,
Value = new MxValue
{
DataType = MxDataType.String,
StringValue = "value",
},
},
};
MxCommandRequest request = new()
{
SessionId = "session-1",
Command = command.Clone(),
};
WorkerCommand fromCommand = mapper.MapCommand(command);
WorkerCommand fromRequest = mapper.MapCommand(request);
command.Write.Value.StringValue = "changed";
Assert.Equal(MxCommandKind.Write, fromCommand.Command.Kind);
Assert.Equal("value", fromCommand.Command.Write.Value.StringValue);
Assert.NotNull(fromCommand.EnqueueTimestamp);
Assert.Equal(fromRequest, fromCommand);
}
/// <summary>Verifies that command reply mapping preserves HRESULT and status information.</summary>
[Fact]
public void MapCommandReply_PreservesHresultStatusesAndPayload()