feat(gateway): normalize array AddItem suffix and expand sparse writes at the worker boundary
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Integration coverage for the single outbound choke point
|
||||
/// (<see cref="GatewaySession.InvokeAsync(WorkerCommand, System.Threading.CancellationToken)"/>):
|
||||
/// array <c>AddItem</c> addresses gain the writable <c>[]</c> suffix and sparse array writes are
|
||||
/// expanded to whole-array values before any command reaches the worker.
|
||||
/// </summary>
|
||||
public sealed class GatewayArrayWriteWiringTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A bare array <c>AddItem</c> address is normalized to its writable array form on the wire,
|
||||
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemDefinition = "Obj.Arr",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
worker.NextReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AddItem = new AddItemReply { ItemHandle = 42 },
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(worker.LastCommand);
|
||||
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddItem.ItemDefinition);
|
||||
|
||||
// Track the reply through the same path the gRPC service uses; the registration must carry
|
||||
// the normalized address even though the public reply is tracked from a separate command copy.
|
||||
MxCommand trackingCopy = new()
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemDefinition = "Obj.Arr",
|
||||
},
|
||||
};
|
||||
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
|
||||
|
||||
Assert.True(session.TryGetItemRegistration(1, 42, out SessionItemRegistration registration));
|
||||
Assert.Equal("Obj.Arr[]", registration.TagAddress);
|
||||
}
|
||||
|
||||
/// <summary>A bare scalar <c>AddItem</c> address is forwarded unchanged.</summary>
|
||||
[Fact]
|
||||
public async Task AddItem_ScalarAddress_ForwardedUnchanged()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemDefinition = "Obj.Scalar",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
Assert.Equal("Obj.Scalar", worker.LastCommand!.Command.AddItem.ItemDefinition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array <see cref="WriteCommand"/> value is expanded to a full, default-filled
|
||||
/// <see cref="MxArray"/> before reaching the worker; no sparse value is ever forwarded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write,
|
||||
Write = new WriteCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 1,
|
||||
Value = new MxValue { Int32Value = 7 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.Write.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
sessionId: "session-array-write-wiring",
|
||||
backendName: "mxaccess",
|
||||
pipeName: "mxaccess-gateway-1-session-array-write-wiring",
|
||||
nonce: "nonce",
|
||||
clientIdentity: "client-1",
|
||||
ownerKeyId: null,
|
||||
clientSessionName: "test-session",
|
||||
clientCorrelationId: "client-correlation-1",
|
||||
commandTimeout: TimeSpan.FromSeconds(5),
|
||||
startupTimeout: TimeSpan.FromSeconds(5),
|
||||
shutdownTimeout: TimeSpan.FromSeconds(5),
|
||||
leaseDuration: TimeSpan.FromMinutes(30),
|
||||
openedAt: DateTimeOffset.UtcNow,
|
||||
addressNormalizer: CreateNormalizer());
|
||||
session.AttachWorkerClient(workerClient);
|
||||
session.MarkReady();
|
||||
return session;
|
||||
}
|
||||
|
||||
private static ArrayAddressNormalizer CreateNormalizer()
|
||||
{
|
||||
IReadOnlyList<GalaxyObject> objects =
|
||||
[
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 1,
|
||||
TagName = "Obj",
|
||||
ContainedName = "Obj",
|
||||
Attributes =
|
||||
{
|
||||
new GalaxyAttribute
|
||||
{
|
||||
AttributeName = "Arr",
|
||||
FullTagReference = "Obj.Arr[]",
|
||||
IsArray = true,
|
||||
},
|
||||
new GalaxyAttribute
|
||||
{
|
||||
AttributeName = "Scalar",
|
||||
FullTagReference = "Obj.Scalar",
|
||||
IsArray = false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
};
|
||||
|
||||
return new ArrayAddressNormalizer(new StubGalaxyHierarchyCache(entry));
|
||||
}
|
||||
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class CapturingWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <summary>Gets the most recent command forwarded to the worker.</summary>
|
||||
public WorkerCommand? LastCommand { get; private set; }
|
||||
|
||||
/// <summary>Gets or sets the reply returned by the next invocation.</summary>
|
||||
public WorkerCommandReply NextReply { get; set; } = new();
|
||||
|
||||
/// <summary>Gets the session identifier.</summary>
|
||||
public string SessionId { get; } = "session-array-write-wiring";
|
||||
|
||||
/// <summary>Gets the worker process identifier.</summary>
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
/// <summary>Gets the worker client state.</summary>
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
/// <summary>Gets the last recorded heartbeat timestamp.</summary>
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastCommand = command;
|
||||
return Task.FromResult(NextReply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user