Add bulk read/write command family across worker, gateway, and clients
Adds five new MXAccess command kinds (WriteBulk, Write2Bulk,
WriteSecuredBulk, WriteSecured2Bulk, ReadBulk) that ride the existing
"one round-trip, per-entry results" bulk shape used by AddItemBulk and
SubscribeBulk today. MXAccess COM has no native bulk API; the worker
runs each bulk operation as a sequential loop on its STA, returning
one BulkWriteResult / BulkReadResult per requested entry so per-item
MXAccess failures surface as was_successful=false rather than throwing.
ReadBulk has no MXAccess analogue. The worker satisfies it by:
- Returning the last cached OnDataChange payload (was_cached=true)
when the requested tag is already in the session''s item registry
AND advised — the existing subscription is NOT touched, since the
caller did not create it.
- Otherwise taking the AddItem + Advise + wait-for-OnDataChange +
UnAdvise + RemoveItem snapshot lifecycle itself (was_cached=false)
and leaving the session exactly as it was. The wait pumps Windows
messages on the STA so the inbound MXAccess event can dispatch
while the executor still holds the thread.
The new MxAccessValueCache lives on each MxAccessSession, shared with
MxAccessBaseEventSink which populates it on every OnDataChange after
the event clears the outbound queue. Eviction on RemoveItem keeps
reused MXAccess handles from serving stale values from a previous
lifetime.
Gateway-side authorization wires WriteBulk/Write2Bulk to invoke:write,
WriteSecuredBulk/WriteSecured2Bulk to invoke:secure, ReadBulk to
invoke:read. The constraint-filter pipeline is refactored from a single
BulkConstraintPlan record into an abstract base plus three concretes
(SubscribeBulk, WriteBulk, ReadBulk), each owning its own denied-entry
merge so the dispatch site never branches on reply shape. A new
FilterWriteBulkAsync<TEntry> generic over the four write-entry shapes
runs CheckWriteHandleAsync per entry; denied entries surface as the
BulkWriteResult shape, preserving original-index order.
All five language clients (.NET, Go, Rust, Python, Java) gained the
five new methods following their existing bulk pattern, with regenerated
protobufs.
Tests added:
- MxAccessValueCacheTests (6 cases) — Set/TryGet, Remove resets the
version, TryWaitForUpdate signals on Set, pump step fires each poll.
- MxAccessBaseEventSinkTests — OnDataChange populates the cache,
ValueCache property exposes the bound instance.
- MxAccessCommandExecutorTests — four bulk-write variants (per-entry
success/failure, value+timestamp forwarding, secured user ids),
ReadBulk snapshot lifecycle on uncached tag (timeout surfaces as
was_successful=false), invalid-payload reply.
- GatewayGrpcScopeResolverTests — five new MxCommandKind cases.
- SessionManagerTests — WriteBulk and ReadBulk forwarding through
FakeWorkerHarness; ReadBulk forwards timeout_ms.
- Per-client (.NET, Go, Rust, Python, Java) — WriteBulk builds the
right command and returns per-entry results, ReadBulk forwards the
timeout and unpacks the was_cached flag.
Cross-language e2e CLI subcommands for the new bulks are deliberately
scoped out of this change (each of the five client CLIs would need
five new subcommands plus matching phases in
scripts/run-client-e2e-tests.ps1); coverage equivalent to the existing
bulk-subscribe coverage is provided by worker + gateway + per-client
unit tests.
Docs updated in the same commit: gateway.md (Public MXAccess Command
Surface), docs/DesignDecisions.md (new "Bulk Command Family" section
with the ReadBulk cache-then-snapshot rationale), and every client
README.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -184,6 +184,96 @@ public sealed class MxGatewayClientSessionTests
|
||||
Assert.Equal(["Area001.Pump001.Speed"], request.Command.SubscribeBulk.TagAddresses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WriteBulk builds one command carrying the entry list verbatim
|
||||
/// and returns the per-entry BulkWriteResult list without throwing on per-entry
|
||||
/// failures.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_BuildsOneBulkCommandAndReturnsPerEntryResults()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
WriteBulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "Invalid handle" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } },
|
||||
new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } },
|
||||
});
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results[0].WasSuccessful);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.WriteBulk, request.Command.Kind);
|
||||
Assert.Equal(12, request.Command.WriteBulk.ServerHandle);
|
||||
Assert.Equal(2, request.Command.WriteBulk.Entries.Count);
|
||||
Assert.Equal(901, request.Command.WriteBulk.Entries[0].ItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that ReadBulk forwards the timeout to the gateway as milliseconds
|
||||
/// and unpacks the BulkReadReply payload's was_cached / value fields.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_ForwardsTimeoutAndUnpacksCachedFlag()
|
||||
{
|
||||
FakeGatewayTransport transport = CreateTransport();
|
||||
transport.AddInvokeReply(new MxCommandReply
|
||||
{
|
||||
SessionId = "session-fixture",
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
ReadBulk = new BulkReadReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 12,
|
||||
TagAddress = "Area001.Pump001.Speed",
|
||||
ItemHandle = 901,
|
||||
WasSuccessful = true,
|
||||
WasCached = true,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 99 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await using MxGatewayClient client = CreateClient(transport);
|
||||
MxGatewaySession session = await client.OpenSessionAsync();
|
||||
|
||||
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
|
||||
12,
|
||||
["Area001.Pump001.Speed"],
|
||||
TimeSpan.FromMilliseconds(750));
|
||||
|
||||
BulkReadResult result = Assert.Single(results);
|
||||
Assert.True(result.WasCached);
|
||||
Assert.Equal(99, result.Value.Int32Value);
|
||||
MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
|
||||
Assert.Equal(MxCommandKind.ReadBulk, request.Command.Kind);
|
||||
Assert.Equal(750u, request.Command.ReadBulk.TimeoutMs);
|
||||
Assert.Equal(["Area001.Pump001.Speed"], request.Command.ReadBulk.TagAddresses);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
||||
[Fact]
|
||||
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||
|
||||
@@ -527,6 +527,142 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
||||
return reply.UnsubscribeBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk Write — sequential MXAccess Write per entry on the worker's STA.
|
||||
/// Per-item failures appear as BulkWriteResult entries with
|
||||
/// <c>WasSuccessful = false</c>; the call never throws on per-item errors.
|
||||
/// Protocol-level failures still throw via EnsureProtocolSuccess.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteBulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteBulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
WriteBulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.WriteBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.</summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<Write2BulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
Write2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.Write2Bulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk WriteSecured — sequential MXAccess WriteSecured per entry.
|
||||
/// Credential-sensitive values must never reach logs; the client mirrors
|
||||
/// the single-item WriteSecured redaction contract.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecuredBulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.WriteSecuredBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per entry.</summary>
|
||||
public async Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecured2BulkCommand command = new() { ServerHandle = serverHandle };
|
||||
command.Entries.Add(entries);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.WriteSecured2Bulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk Read — snapshot the current value for each requested tag.
|
||||
/// Returns the cached OnDataChange value when the tag is already advised
|
||||
/// (was_cached = true), otherwise the worker takes the full AddItem +
|
||||
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle. Per-tag
|
||||
/// failures (timeout, invalid tag) appear as BulkReadResult entries with
|
||||
/// <c>WasSuccessful = false</c>; the call never throws on per-tag errors.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||
|
||||
ReadBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
|
||||
};
|
||||
command.TagAddresses.Add(tagAddresses);
|
||||
|
||||
MxCommandReply reply = await InvokeCommandAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ReadBulk = command,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
|
||||
return reply.ReadBulk?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a value to an item on the MXAccess server.
|
||||
/// </summary>
|
||||
|
||||
@@ -84,6 +84,48 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
|
||||
available, and command helpers have `*RawAsync` variants when callers need the
|
||||
complete `MxCommandReply`.
|
||||
|
||||
### Bulk Commands
|
||||
|
||||
The session exposes bulk variants for every command family that has one
|
||||
upstream — they all carry a list of entries in one gRPC round-trip, the worker
|
||||
runs the per-item MXAccess calls sequentially on its STA, and the reply
|
||||
returns one result per requested entry. Per-entry failures populate
|
||||
`WasSuccessful = false` with the underlying HRESULT and never throw; only
|
||||
protocol-level failures throw via `EnsureProtocolSuccess`.
|
||||
|
||||
```csharp
|
||||
// Subscribe + Unsubscribe to a batch of tags in one round-trip
|
||||
IReadOnlyList<SubscribeResult> subResults = await session.SubscribeBulkAsync(
|
||||
serverHandle,
|
||||
new[] { "Area001.Pump001.Speed", "Area001.Pump001.RunHours" });
|
||||
int[] itemHandles = subResults.Where(r => r.WasSuccessful).Select(r => r.ItemHandle).ToArray();
|
||||
await session.UnsubscribeBulkAsync(serverHandle, itemHandles);
|
||||
|
||||
// Bulk Write — sequential MXAccess Write per entry.
|
||||
IReadOnlyList<BulkWriteResult> writeResults = await session.WriteBulkAsync(
|
||||
serverHandle,
|
||||
new[]
|
||||
{
|
||||
new WriteBulkEntry { ItemHandle = h1, UserId = 0, Value = 1.0.ToMxValue() },
|
||||
new WriteBulkEntry { ItemHandle = h2, UserId = 0, Value = 2.0.ToMxValue() },
|
||||
});
|
||||
foreach (BulkWriteResult r in writeResults.Where(r => !r.WasSuccessful))
|
||||
{
|
||||
Console.Error.WriteLine($"item {r.ItemHandle}: {r.ErrorMessage}");
|
||||
}
|
||||
|
||||
// Bulk Read — returns the cached OnDataChange value when the tag is already
|
||||
// advised (was_cached = true) or takes a one-shot snapshot otherwise.
|
||||
IReadOnlyList<BulkReadResult> readResults = await session.ReadBulkAsync(
|
||||
serverHandle,
|
||||
new[] { "Area001.Pump001.Speed", "Area001.Pump002.Speed" },
|
||||
timeout: TimeSpan.FromMilliseconds(750));
|
||||
```
|
||||
|
||||
`Write2BulkAsync`, `WriteSecuredBulkAsync`, and `WriteSecured2BulkAsync` follow
|
||||
the same shape; the secured variants additionally carry `CurrentUserId` and
|
||||
`VerifierUserId` per entry and require `invoke:secure` scope.
|
||||
|
||||
`MxGatewaySession.CloseAsync` is explicit and idempotent. Repeated calls return
|
||||
the first `CloseSessionReply` instead of sending another close request.
|
||||
|
||||
|
||||
+10
-1
@@ -76,7 +76,16 @@ client, err := mxgateway.Dial(ctx, mxgateway.Options{
|
||||
```
|
||||
|
||||
`Client.OpenSession` returns a `Session` with helpers for `Register`,
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
|
||||
`AddItem`, `AddItem2`, `Advise`, `Write`, the full bulk family
|
||||
(`AddItemBulk`, `AdviseItemBulk`, `RemoveItemBulk`, `UnAdviseItemBulk`,
|
||||
`SubscribeBulk`, `UnsubscribeBulk`, `WriteBulk`, `Write2Bulk`,
|
||||
`WriteSecuredBulk`, `WriteSecured2Bulk`, `ReadBulk`), `Events`, and
|
||||
`Close`. Bulk variants carry a list of entries in one round-trip and
|
||||
return one result per entry; per-entry MXAccess failures appear as
|
||||
`was_successful = false` and never return as Go errors. `ReadBulk` accepts
|
||||
a `time.Duration` per-tag timeout and returns cached `OnDataChange`
|
||||
values when the tag is already advised (`WasCached = true`) without
|
||||
touching the existing subscription. Prefer
|
||||
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
|
||||
returned subscription owns cancellation and exposes `Close` for deterministic
|
||||
goroutine cleanup. `Events` and `EventsAfter` are a compatibility shim with a
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -243,6 +243,87 @@ func TestSubscribeBulkBuildsOneBulkCommandAndReturnsResults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteBulkBuildsOneBulkCommandAndReturnsPerEntryResults(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||
},
|
||||
Payload: &pb.MxCommandReply_WriteBulk{
|
||||
WriteBulk: &pb.BulkWriteReply{
|
||||
Results: []*pb.BulkWriteResult{
|
||||
{ServerHandle: 12, ItemHandle: 901, WasSuccessful: true},
|
||||
{ServerHandle: 12, ItemHandle: 902, WasSuccessful: false, ErrorMessage: "invalid handle"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
results, err := session.WriteBulk(context.Background(), 12, []*pb.WriteBulkEntry{
|
||||
{ItemHandle: 901, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 11}}},
|
||||
{ItemHandle: 902, UserId: 5, Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 22}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteBulk() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 || !results[0].GetWasSuccessful() || results[1].GetWasSuccessful() {
|
||||
t.Fatalf("results = %#v, want [success, failure]", results)
|
||||
}
|
||||
req := fake.invokeRequest
|
||||
if req.GetCommand().GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK {
|
||||
t.Fatalf("command kind = %s", req.GetCommand().GetKind())
|
||||
}
|
||||
if got := req.GetCommand().GetWriteBulk().GetEntries(); len(got) != 2 {
|
||||
t.Fatalf("entries = %#v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBulkForwardsTimeoutAndUnpacksCachedFlag(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
invokeReply: &pb.MxCommandReply{
|
||||
SessionId: "session-1",
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||
ProtocolStatus: &pb.ProtocolStatus{
|
||||
Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK,
|
||||
},
|
||||
Payload: &pb.MxCommandReply_ReadBulk{
|
||||
ReadBulk: &pb.BulkReadReply{
|
||||
Results: []*pb.BulkReadResult{
|
||||
{
|
||||
ServerHandle: 12,
|
||||
TagAddress: "Area001.Pump001.Speed",
|
||||
ItemHandle: 34,
|
||||
WasSuccessful: true,
|
||||
WasCached: true,
|
||||
Value: &pb.MxValue{DataType: pb.MxDataType_MX_DATA_TYPE_INTEGER, Kind: &pb.MxValue_Int32Value{Int32Value: 99}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
results, err := session.ReadBulk(context.Background(), 12, []string{"Area001.Pump001.Speed"}, 750*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadBulk() error = %v", err)
|
||||
}
|
||||
if len(results) != 1 || !results[0].GetWasCached() || results[0].GetValue().GetInt32Value() != 99 {
|
||||
t.Fatalf("results = %#v", results)
|
||||
}
|
||||
if got := fake.invokeRequest.GetCommand().GetReadBulk().GetTimeoutMs(); got != 750 {
|
||||
t.Fatalf("timeout_ms = %d, want 750", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeReturnsTypedMxAccessErrorWithRawReply(t *testing.T) {
|
||||
hresult := int32(-2147467259)
|
||||
fake := &fakeGatewayServer{
|
||||
|
||||
@@ -389,6 +389,142 @@ func (s *Session) UnsubscribeBulk(ctx context.Context, serverHandle int32, itemH
|
||||
return reply.GetUnsubscribeBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// WriteBulk invokes MXAccess Write sequentially for each entry inside one gateway command.
|
||||
// Per-entry failures appear as BulkWriteResult entries with WasSuccessful=false; the call
|
||||
// never returns an error for per-entry MXAccess failures (it returns an error only for
|
||||
// protocol-level failures or transport errors).
|
||||
func (s *Session) WriteBulk(ctx context.Context, serverHandle int32, entries []*WriteBulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK,
|
||||
Payload: &pb.MxCommand_WriteBulk{
|
||||
WriteBulk: &pb.WriteBulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWriteBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// Write2Bulk invokes MXAccess Write2 (timestamped) for each entry inside one gateway command.
|
||||
func (s *Session) Write2Bulk(ctx context.Context, serverHandle int32, entries []*Write2BulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write2 bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write2 bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK,
|
||||
Payload: &pb.MxCommand_Write2Bulk{
|
||||
Write2Bulk: &pb.Write2BulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWrite2Bulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// WriteSecuredBulk invokes MXAccess WriteSecured for each entry. Credential-sensitive
|
||||
// values must not be logged by callers; mirrors the single-item WriteSecured contract.
|
||||
func (s *Session) WriteSecuredBulk(ctx context.Context, serverHandle int32, entries []*WriteSecuredBulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write-secured bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write-secured bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||
Payload: &pb.MxCommand_WriteSecuredBulk{
|
||||
WriteSecuredBulk: &pb.WriteSecuredBulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWriteSecuredBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// WriteSecured2Bulk invokes MXAccess WriteSecured2 (timestamped) for each entry.
|
||||
func (s *Session) WriteSecured2Bulk(ctx context.Context, serverHandle int32, entries []*WriteSecured2BulkEntry) ([]*BulkWriteResult, error) {
|
||||
if entries == nil {
|
||||
return nil, errors.New("mxgateway: write-secured2 bulk entries are required")
|
||||
}
|
||||
if err := ensureBulkSize("write-secured2 bulk entries", len(entries)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||
Payload: &pb.MxCommand_WriteSecured2Bulk{
|
||||
WriteSecured2Bulk: &pb.WriteSecured2BulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
Entries: entries,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetWriteSecured2Bulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// ReadBulk snapshots the current value of each requested tag.
|
||||
//
|
||||
// MXAccess COM has no synchronous Read; the worker satisfies this by returning the
|
||||
// most recent cached OnDataChange value when the tag is already advised (WasCached=true),
|
||||
// or by taking a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||
// otherwise. timeout bounds the wait per tag in the snapshot case; pass zero to use the
|
||||
// worker default. Per-tag failures (timeout, invalid tag) appear as BulkReadResult entries
|
||||
// with WasSuccessful=false; the call never returns an error for per-tag MXAccess failures.
|
||||
func (s *Session) ReadBulk(ctx context.Context, serverHandle int32, tagAddresses []string, timeout time.Duration) ([]*BulkReadResult, error) {
|
||||
if tagAddresses == nil {
|
||||
return nil, errors.New("mxgateway: tag addresses are required")
|
||||
}
|
||||
if err := ensureBulkSize("tag addresses", len(tagAddresses)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var timeoutMs uint32
|
||||
if timeout > 0 {
|
||||
ms := timeout.Milliseconds()
|
||||
if ms > int64(^uint32(0)) {
|
||||
timeoutMs = ^uint32(0)
|
||||
} else {
|
||||
timeoutMs = uint32(ms)
|
||||
}
|
||||
}
|
||||
reply, err := s.invokeCommand(ctx, &pb.MxCommand{
|
||||
Kind: pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK,
|
||||
Payload: &pb.MxCommand_ReadBulk{
|
||||
ReadBulk: &pb.ReadBulkCommand{
|
||||
ServerHandle: serverHandle,
|
||||
TagAddresses: tagAddresses,
|
||||
TimeoutMs: timeoutMs,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reply.GetReadBulk().GetResults(), nil
|
||||
}
|
||||
|
||||
// Write invokes MXAccess Write.
|
||||
func (s *Session) Write(ctx context.Context, serverHandle, itemHandle int32, value *MxValue, userID int32) error {
|
||||
_, err := s.WriteRaw(ctx, serverHandle, itemHandle, value, userID)
|
||||
|
||||
@@ -70,6 +70,32 @@ type (
|
||||
WriteCommand = pb.WriteCommand
|
||||
// Write2Command is the payload of an MXAccess Write2 command.
|
||||
Write2Command = pb.Write2Command
|
||||
// WriteBulkCommand carries one bulk-Write request.
|
||||
WriteBulkCommand = pb.WriteBulkCommand
|
||||
// WriteBulkEntry is one (item_handle, value, user_id) tuple in a WriteBulk request.
|
||||
WriteBulkEntry = pb.WriteBulkEntry
|
||||
// Write2BulkCommand carries one bulk-Write2 (timestamped) request.
|
||||
Write2BulkCommand = pb.Write2BulkCommand
|
||||
// Write2BulkEntry is one (item_handle, value, timestamp_value, user_id) tuple in a Write2Bulk request.
|
||||
Write2BulkEntry = pb.Write2BulkEntry
|
||||
// WriteSecuredBulkCommand carries one bulk-WriteSecured request. Values are credential-sensitive.
|
||||
WriteSecuredBulkCommand = pb.WriteSecuredBulkCommand
|
||||
// WriteSecuredBulkEntry is one entry in a WriteSecuredBulk request.
|
||||
WriteSecuredBulkEntry = pb.WriteSecuredBulkEntry
|
||||
// WriteSecured2BulkCommand carries one bulk-WriteSecured2 (timestamped) request.
|
||||
WriteSecured2BulkCommand = pb.WriteSecured2BulkCommand
|
||||
// WriteSecured2BulkEntry is one entry in a WriteSecured2Bulk request.
|
||||
WriteSecured2BulkEntry = pb.WriteSecured2BulkEntry
|
||||
// ReadBulkCommand carries one bulk-Read request.
|
||||
ReadBulkCommand = pb.ReadBulkCommand
|
||||
// BulkWriteResult is one per-entry result in a bulk-write reply.
|
||||
BulkWriteResult = pb.BulkWriteResult
|
||||
// BulkWriteReply aggregates BulkWriteResult entries for a bulk-write command.
|
||||
BulkWriteReply = pb.BulkWriteReply
|
||||
// BulkReadResult is one per-tag result in a bulk-read reply (carries the snapshot value plus a was_cached flag).
|
||||
BulkReadResult = pb.BulkReadResult
|
||||
// BulkReadReply aggregates BulkReadResult entries for a ReadBulk command.
|
||||
BulkReadReply = pb.BulkReadReply
|
||||
// RegisterReply carries the ServerHandle returned by Register.
|
||||
RegisterReply = pb.RegisterReply
|
||||
// AddItemReply carries the ItemHandle returned by AddItem.
|
||||
@@ -155,6 +181,16 @@ const (
|
||||
CommandKindWrite = pb.MxCommandKind_MX_COMMAND_KIND_WRITE
|
||||
// CommandKindWrite2 selects the MXAccess Write2 command.
|
||||
CommandKindWrite2 = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2
|
||||
// CommandKindWriteBulk selects the bulk Write command.
|
||||
CommandKindWriteBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_BULK
|
||||
// CommandKindWrite2Bulk selects the bulk Write2 (timestamped) command.
|
||||
CommandKindWrite2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE2_BULK
|
||||
// CommandKindWriteSecuredBulk selects the bulk WriteSecured command.
|
||||
CommandKindWriteSecuredBulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED_BULK
|
||||
// CommandKindWriteSecured2Bulk selects the bulk WriteSecured2 (timestamped) command.
|
||||
CommandKindWriteSecured2Bulk = pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2_BULK
|
||||
// CommandKindReadBulk selects the bulk Read command (cached-or-snapshot per tag).
|
||||
CommandKindReadBulk = pb.MxCommandKind_MX_COMMAND_KIND_READ_BULK
|
||||
|
||||
// DataTypeUnknown denotes an unrecognized MXAccess data type.
|
||||
DataTypeUnknown = pb.MxDataType_MX_DATA_TYPE_UNKNOWN
|
||||
|
||||
@@ -62,6 +62,18 @@ underlying protobuf messages. `MxGatewayCommandException` and
|
||||
`MxAccessException` preserve the raw `MxCommandReply` when the gateway returns a
|
||||
data-bearing MXAccess failure.
|
||||
|
||||
`MxGatewaySession` exposes the full bulk family — `addItemBulk`,
|
||||
`adviseItemBulk`, `removeItemBulk`, `unAdviseItemBulk`, `subscribeBulk`,
|
||||
`unsubscribeBulk`, `writeBulk`, `write2Bulk`, `writeSecuredBulk`,
|
||||
`writeSecured2Bulk`, and `readBulk`. Each carries one round-trip with a
|
||||
`List<*Entry>` (or `List<String>` / `List<Integer>` for the legacy bulk
|
||||
shapes) and returns `List<SubscribeResult>` / `List<BulkWriteResult>` /
|
||||
`List<BulkReadResult>`; per-entry MXAccess failures populate
|
||||
`wasSuccessful == false` and never throw. `readBulk` takes a per-tag
|
||||
`timeoutMs` (0 = worker default) and returns cached `OnDataChange` values
|
||||
when the tag is already advised (`wasCached == true`) without touching the
|
||||
existing subscription.
|
||||
|
||||
`openSession` verifies the gateway's reported `gateway_protocol_version` against
|
||||
the version this client was generated for and throws `MxGatewayException` on a
|
||||
mismatch, so an incompatible client fails fast with a clear message instead of
|
||||
|
||||
+109
@@ -9,6 +9,8 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommand;
|
||||
@@ -17,6 +19,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
|
||||
@@ -27,8 +30,16 @@ import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.UnregisterCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2BulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkEntry;
|
||||
|
||||
/**
|
||||
* Typed handle for a single MXAccess gateway session.
|
||||
@@ -451,6 +462,104 @@ public final class MxGatewaySession implements AutoCloseable {
|
||||
return reply.getUnsubscribeBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Write} — sequential MXAccess Write per entry on the worker's STA.
|
||||
* Per-entry failures appear as {@link BulkWriteResult} entries with
|
||||
* {@code wasSuccessful == false}; this method does not throw for per-entry
|
||||
* MXAccess failures (it still throws {@link MxGatewayException} on transport
|
||||
* or protocol-level failures).
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param entries the per-item (handle, value, user id) tuples
|
||||
* @return a per-entry {@link BulkWriteResult} list
|
||||
*/
|
||||
public List<BulkWriteResult> writeBulk(int serverHandle, List<WriteBulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK)
|
||||
.setWriteBulk(WriteBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Write2} — sequential MXAccess Write2 (timestamped) per entry.
|
||||
*/
|
||||
public List<BulkWriteResult> write2Bulk(int serverHandle, List<Write2BulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE2_BULK)
|
||||
.setWrite2Bulk(Write2BulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWrite2Bulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code WriteSecured} — credential-sensitive values must not be logged
|
||||
* by callers; mirrors the single-item write-secured redaction contract.
|
||||
*/
|
||||
public List<BulkWriteResult> writeSecuredBulk(int serverHandle, List<WriteSecuredBulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED_BULK)
|
||||
.setWriteSecuredBulk(WriteSecuredBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteSecuredBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code WriteSecured2} — sequential timestamped + verified write per entry.
|
||||
*/
|
||||
public List<BulkWriteResult> writeSecured2Bulk(int serverHandle, List<WriteSecured2BulkEntry> entries) {
|
||||
Objects.requireNonNull(entries, "entries");
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2_BULK)
|
||||
.setWriteSecured2Bulk(WriteSecured2BulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllEntries(entries))
|
||||
.build());
|
||||
return reply.getWriteSecured2Bulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk {@code Read} — snapshot the current value of each requested tag.
|
||||
*
|
||||
* <p>MXAccess COM has no synchronous read; the worker returns the cached
|
||||
* {@code OnDataChange} value for any tag that is already advised
|
||||
* ({@code wasCached == true}) without modifying the existing subscription,
|
||||
* and falls back to a full AddItem + Advise + wait + UnAdvise + RemoveItem
|
||||
* snapshot lifecycle otherwise. {@code timeoutMs} bounds the per-tag wait
|
||||
* in the snapshot case; pass {@code 0} to use the worker default (1000 ms).
|
||||
* Per-tag failures appear as {@link BulkReadResult} entries with
|
||||
* {@code wasSuccessful == false}; this method does not throw for per-tag
|
||||
* MXAccess failures.
|
||||
*
|
||||
* @param serverHandle the {@code ServerHandle} owning the items
|
||||
* @param tagAddresses the tag addresses to read
|
||||
* @param timeoutMs per-tag snapshot timeout in milliseconds (0 = worker default)
|
||||
* @return a per-tag {@link BulkReadResult} list
|
||||
*/
|
||||
public List<BulkReadResult> readBulk(int serverHandle, List<String> tagAddresses, int timeoutMs) {
|
||||
Objects.requireNonNull(tagAddresses, "tagAddresses");
|
||||
if (timeoutMs < 0) {
|
||||
throw new IllegalArgumentException("timeoutMs must be non-negative");
|
||||
}
|
||||
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
|
||||
.setKind(MxCommandKind.MX_COMMAND_KIND_READ_BULK)
|
||||
.setReadBulk(ReadBulkCommand.newBuilder()
|
||||
.setServerHandle(serverHandle)
|
||||
.addAllTagAddresses(tagAddresses)
|
||||
.setTimeoutMs(timeoutMs))
|
||||
.build());
|
||||
return reply.getReadBulk().getResultsList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes MXAccess {@code Write}.
|
||||
*
|
||||
|
||||
+88
@@ -24,7 +24,14 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import mxaccess_gateway.v1.MxAccessGatewayGrpc;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxValue;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
|
||||
import mxaccess_gateway.v1.MxaccessGateway.MxCommandKind;
|
||||
@@ -151,6 +158,87 @@ final class MxGatewayClientSessionTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeBulkBuildsOneBulkCommandAndReturnsPerEntryResults() throws Exception {
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
commandRequest.set(request);
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.setWriteBulk(BulkWriteReply.newBuilder()
|
||||
.addResults(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(12).setItemHandle(901).setWasSuccessful(true))
|
||||
.addResults(BulkWriteResult.newBuilder()
|
||||
.setServerHandle(12).setItemHandle(902).setWasSuccessful(false)
|
||||
.setErrorMessage("invalid handle")))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
|
||||
|
||||
List<BulkWriteResult> results = session.writeBulk(12, List.of(
|
||||
WriteBulkEntry.newBuilder().setItemHandle(901).setUserId(5)
|
||||
.setValue(MxValue.newBuilder().setDataType(MxDataType.MX_DATA_TYPE_INTEGER).setInt32Value(11)).build(),
|
||||
WriteBulkEntry.newBuilder().setItemHandle(902).setUserId(5)
|
||||
.setValue(MxValue.newBuilder().setDataType(MxDataType.MX_DATA_TYPE_INTEGER).setInt32Value(22)).build()));
|
||||
|
||||
assertEquals(2, results.size());
|
||||
assertTrue(results.get(0).getWasSuccessful());
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_WRITE_BULK, commandRequest.get().getCommand().getKind());
|
||||
assertEquals(2, commandRequest.get().getCommand().getWriteBulk().getEntriesCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readBulkForwardsTimeoutAndUnpacksCachedFlag() throws Exception {
|
||||
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
|
||||
TestGatewayService service = new TestGatewayService() {
|
||||
@Override
|
||||
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
|
||||
commandRequest.set(request);
|
||||
responseObserver.onNext(MxCommandReply.newBuilder()
|
||||
.setSessionId(request.getSessionId())
|
||||
.setKind(request.getCommand().getKind())
|
||||
.setProtocolStatus(ok())
|
||||
.setReadBulk(BulkReadReply.newBuilder()
|
||||
.addResults(BulkReadResult.newBuilder()
|
||||
.setServerHandle(12)
|
||||
.setTagAddress("Area001.Pump001.Speed")
|
||||
.setItemHandle(34)
|
||||
.setWasSuccessful(true)
|
||||
.setWasCached(true)
|
||||
.setValue(MxValue.newBuilder()
|
||||
.setDataType(MxDataType.MX_DATA_TYPE_INTEGER)
|
||||
.setInt32Value(99))))
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
|
||||
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
|
||||
MxGatewaySession session = MxGatewaySession.forSessionId(client, "existing-session");
|
||||
|
||||
List<BulkReadResult> results = session.readBulk(12, List.of("Area001.Pump001.Speed"), 750);
|
||||
|
||||
assertEquals(1, results.size());
|
||||
assertTrue(results.get(0).getWasCached());
|
||||
assertEquals(99, results.get(0).getValue().getInt32Value());
|
||||
assertEquals(MxCommandKind.MX_COMMAND_KIND_READ_BULK, commandRequest.get().getCommand().getKind());
|
||||
assertEquals(750, commandRequest.get().getCommand().getReadBulk().getTimeoutMs());
|
||||
assertEquals(List.of("Area001.Pump001.Speed"),
|
||||
commandRequest.get().getCommand().getReadBulk().getTagAddressesList());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamCancellationCancelsServerCall() throws Exception {
|
||||
CountDownLatch cancelled = new CountDownLatch(1);
|
||||
|
||||
+14865
-416
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,17 @@ async with await GatewayClient.connect(
|
||||
events available for parity tests. `Session` helpers call the method-specific
|
||||
MXAccess commands and preserve raw replies on typed command exceptions.
|
||||
|
||||
The full bulk family is available — `add_item_bulk`, `advise_item_bulk`,
|
||||
`remove_item_bulk`, `unadvise_item_bulk`, `subscribe_bulk`, `unsubscribe_bulk`,
|
||||
`write_bulk`, `write2_bulk`, `write_secured_bulk`, `write_secured2_bulk`, and
|
||||
`read_bulk`. Bulk methods carry a list of entries in one round-trip and
|
||||
return a `list[pb.SubscribeResult]` / `list[pb.BulkWriteResult]` /
|
||||
`list[pb.BulkReadResult]`; per-entry MXAccess failures appear as result
|
||||
entries with `was_successful = False` and never raise. `read_bulk` accepts
|
||||
a per-tag `timeout_ms` (`0` = worker default) and returns cached
|
||||
`OnDataChange` values when the tag is already advised
|
||||
(`was_cached = True`) without touching the existing subscription.
|
||||
|
||||
`*_raw` methods (`GatewayClient.invoke_raw`, `Session.invoke_raw`) surface
|
||||
gateway protocol failures by raising the typed `MxGateway*` exceptions, but
|
||||
they deliberately do **not** run MXAccess-failure detection: an MXAccess
|
||||
|
||||
@@ -26,7 +26,14 @@ if _version_not_supported:
|
||||
|
||||
|
||||
class GalaxyRepositoryStub(object):
|
||||
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
database). Lets clients enumerate the deployed object hierarchy and each
|
||||
object's dynamic attributes so they know what tag references to subscribe
|
||||
to via the MxAccessGateway service.
|
||||
@@ -61,7 +68,14 @@ class GalaxyRepositoryStub(object):
|
||||
|
||||
|
||||
class GalaxyRepositoryServicer(object):
|
||||
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
database). Lets clients enumerate the deployed object hierarchy and each
|
||||
object's dynamic attributes so they know what tag references to subscribe
|
||||
to via the MxAccessGateway service.
|
||||
@@ -129,7 +143,14 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class GalaxyRepository(object):
|
||||
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
database). Lets clients enumerate the deployed object hierarchy and each
|
||||
object's dynamic attributes so they know what tag references to subscribe
|
||||
to via the MxAccessGateway service.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -26,7 +26,14 @@ if _version_not_supported:
|
||||
|
||||
|
||||
class MxAccessGatewayStub(object):
|
||||
"""Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
@@ -68,7 +75,14 @@ class MxAccessGatewayStub(object):
|
||||
|
||||
|
||||
class MxAccessGatewayServicer(object):
|
||||
"""Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""
|
||||
|
||||
def OpenSession(self, request, context):
|
||||
@@ -149,7 +163,14 @@ def add_MxAccessGatewayServicer_to_server(servicer, server):
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class MxAccessGateway(object):
|
||||
"""Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
additively only. Never renumber or repurpose an existing field number or
|
||||
enum value. When a field or enum value is removed, add a `reserved` range
|
||||
(and `reserved` name) covering it in the same change so a future editor
|
||||
cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
declarations today because no field or enum value has ever been removed.
|
||||
|
||||
Public client API for MXAccess sessions hosted by the gateway.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -351,6 +351,138 @@ class Session:
|
||||
)
|
||||
return list(reply.unsubscribe_bulk.results)
|
||||
|
||||
async def write_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteBulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteBulk` and return one BulkWriteResult per entry.
|
||||
|
||||
Per-entry MXAccess failures appear as results with ``was_successful = False``
|
||||
and a populated ``error_message`` / ``hresult``; this method does not raise
|
||||
on per-entry failure, mirroring the existing add/advise bulk surface.
|
||||
"""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
||||
write_bulk=pb.WriteBulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_bulk.results)
|
||||
|
||||
async def write2_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.Write2BulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `Write2Bulk` (timestamped) and return per-entry results."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE2_BULK,
|
||||
write2_bulk=pb.Write2BulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write2_bulk.results)
|
||||
|
||||
async def write_secured_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteSecuredBulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteSecuredBulk` — credential-sensitive values must not be logged."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED_BULK,
|
||||
write_secured_bulk=pb.WriteSecuredBulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_secured_bulk.results)
|
||||
|
||||
async def write_secured2_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
entries: Sequence[pb.WriteSecured2BulkEntry],
|
||||
*,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkWriteResult]:
|
||||
"""Invoke MXAccess `WriteSecured2Bulk` (timestamped + verified)."""
|
||||
if entries is None:
|
||||
raise TypeError("entries is required")
|
||||
_ensure_bulk_size("entries", len(entries))
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_SECURED2_BULK,
|
||||
write_secured2_bulk=pb.WriteSecured2BulkCommand(
|
||||
server_handle=server_handle,
|
||||
entries=entries,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.write_secured2_bulk.results)
|
||||
|
||||
async def read_bulk(
|
||||
self,
|
||||
server_handle: int,
|
||||
tag_addresses: Sequence[str],
|
||||
*,
|
||||
timeout_ms: int = 0,
|
||||
correlation_id: str = "",
|
||||
) -> list[pb.BulkReadResult]:
|
||||
"""Invoke `ReadBulk` — snapshot the current value of each requested tag.
|
||||
|
||||
MXAccess COM has no synchronous read; the worker returns the cached
|
||||
``OnDataChange`` value for any tag that is already advised (``was_cached =
|
||||
True``) without modifying the existing subscription, and falls back to
|
||||
a full AddItem + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle
|
||||
otherwise. ``timeout_ms`` bounds the per-tag wait in the snapshot case;
|
||||
pass ``0`` to use the worker default (1000 ms).
|
||||
"""
|
||||
if tag_addresses is None:
|
||||
raise TypeError("tag_addresses is required")
|
||||
_ensure_bulk_size("tag_addresses", len(tag_addresses))
|
||||
if timeout_ms < 0:
|
||||
raise ValueError("timeout_ms must be non-negative")
|
||||
reply = await self.invoke(
|
||||
pb.MxCommand(
|
||||
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
||||
read_bulk=pb.ReadBulkCommand(
|
||||
server_handle=server_handle,
|
||||
tag_addresses=tag_addresses,
|
||||
timeout_ms=timeout_ms,
|
||||
),
|
||||
),
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return list(reply.read_bulk.results)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
server_handle: int,
|
||||
|
||||
@@ -93,6 +93,79 @@ async def test_subscribe_bulk_sends_one_bulk_command_and_returns_results() -> No
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_bulk_sends_one_bulk_command_and_returns_per_entry_results() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
bulk_reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_WRITE_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
write_bulk=pb.BulkWriteReply(
|
||||
results=[
|
||||
pb.BulkWriteResult(server_handle=12, item_handle=901, was_successful=True),
|
||||
pb.BulkWriteResult(server_handle=12, item_handle=902, was_successful=False, error_message="invalid handle"),
|
||||
],
|
||||
),
|
||||
)
|
||||
stub.invoke.replies = [bulk_reply]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
entries = [
|
||||
pb.WriteBulkEntry(item_handle=901, user_id=5, value=pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, int32_value=11)),
|
||||
pb.WriteBulkEntry(item_handle=902, user_id=5, value=pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, int32_value=22)),
|
||||
]
|
||||
results = await session.write_bulk(12, entries)
|
||||
|
||||
assert len(results) == 2
|
||||
assert results[0].was_successful is True
|
||||
assert results[1].was_successful is False
|
||||
sent = stub.invoke.requests[0].command
|
||||
assert sent.kind == pb.MX_COMMAND_KIND_WRITE_BULK
|
||||
assert len(sent.write_bulk.entries) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_bulk_forwards_timeout_and_unpacks_cached_flag() -> None:
|
||||
stub = FakeGatewayStub()
|
||||
bulk_reply = pb.MxCommandReply(
|
||||
session_id="session-1",
|
||||
kind=pb.MX_COMMAND_KIND_READ_BULK,
|
||||
protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
|
||||
read_bulk=pb.BulkReadReply(
|
||||
results=[
|
||||
pb.BulkReadResult(
|
||||
server_handle=12,
|
||||
tag_address="Area001.Pump001.Speed",
|
||||
item_handle=34,
|
||||
was_successful=True,
|
||||
was_cached=True,
|
||||
value=pb.MxValue(data_type=pb.MX_DATA_TYPE_INTEGER, int32_value=99),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
stub.invoke.replies = [bulk_reply]
|
||||
client = await GatewayClient.connect(
|
||||
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
session = await client.open_session()
|
||||
|
||||
results = await session.read_bulk(12, ["Area001.Pump001.Speed"], timeout_ms=750)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].was_cached is True
|
||||
assert results[0].value.int32_value == 99
|
||||
sent = stub.invoke.requests[0].command
|
||||
assert sent.kind == pb.MX_COMMAND_KIND_READ_BULK
|
||||
assert list(sent.read_bulk.tag_addresses) == ["Area001.Pump001.Speed"]
|
||||
assert sent.read_bulk.timeout_ms == 750
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_events_cancels_underlying_call_when_closed() -> None:
|
||||
stream = FakeStream(
|
||||
|
||||
@@ -99,6 +99,16 @@ preserving the raw message for parity diagnostics. Command replies whose
|
||||
protocol status is not `PROTOCOL_STATUS_CODE_OK` become `Error::Command` and
|
||||
retain the raw `MxCommandReply`.
|
||||
|
||||
The session also exposes the full bulk family —
|
||||
`add_item_bulk`, `advise_item_bulk`, `remove_item_bulk`, `un_advise_item_bulk`,
|
||||
`subscribe_bulk`, `unsubscribe_bulk`, `write_bulk`, `write2_bulk`,
|
||||
`write_secured_bulk`, `write_secured2_bulk`, and `read_bulk`. Each carries a
|
||||
`Vec` of entries in one round-trip and returns one result per entry; per-entry
|
||||
MXAccess failures populate `was_successful = false` and never raise. `read_bulk`
|
||||
takes a per-tag timeout (`u32` milliseconds, `0` = worker default) and returns
|
||||
the cached `OnDataChange` value when the tag is already advised (`was_cached =
|
||||
true`) without touching the existing subscription.
|
||||
|
||||
## Galaxy Repository browse
|
||||
|
||||
The Galaxy Repository service exposes a read-only browse over the AVEVA System
|
||||
|
||||
+181
-4
@@ -16,10 +16,13 @@ use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
|
||||
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
|
||||
use crate::generated::mxaccess_gateway::v1::{
|
||||
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
|
||||
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply, MxCommandRequest,
|
||||
MxValue as ProtoMxValue, OpenSessionRequest, RegisterCommand, RemoveItemBulkCommand,
|
||||
RemoveItemCommand, StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
|
||||
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2Command, WriteCommand,
|
||||
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
|
||||
MxCommandRequest, MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand,
|
||||
RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, StreamEventsRequest,
|
||||
SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, UnAdviseItemBulkCommand,
|
||||
UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, Write2Command, WriteBulkCommand,
|
||||
WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, WriteSecured2BulkEntry,
|
||||
WriteSecuredBulkCommand, WriteSecuredBulkEntry,
|
||||
};
|
||||
use crate::value::MxValue;
|
||||
|
||||
@@ -362,6 +365,147 @@ impl Session {
|
||||
bulk_results(reply, BulkReplyKind::Unsubscribe)
|
||||
}
|
||||
|
||||
/// Bulk `Write` (sequential MXAccess Write per entry, on the worker's STA).
|
||||
///
|
||||
/// Per-entry MXAccess failures are reported as `BulkWriteResult` entries
|
||||
/// with `was_successful = false`; the call never errors on per-entry
|
||||
/// failure. Protocol-level failures still surface as [`Error::Command`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::add_item_bulk`], plus the usual
|
||||
/// transport/status errors.
|
||||
pub async fn write_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<WriteBulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::WriteBulk,
|
||||
Payload::WriteBulk(WriteBulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::Write)
|
||||
}
|
||||
|
||||
/// Bulk `Write2` (timestamped) — see [`Session::write_bulk`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::write_bulk`].
|
||||
pub async fn write2_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<Write2BulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::Write2Bulk,
|
||||
Payload::Write2Bulk(Write2BulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::Write2)
|
||||
}
|
||||
|
||||
/// Bulk `WriteSecured` — credential-sensitive values follow the same
|
||||
/// redaction contract as the single-item `write_secured` path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::write_bulk`].
|
||||
pub async fn write_secured_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<WriteSecuredBulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::WriteSecuredBulk,
|
||||
Payload::WriteSecuredBulk(WriteSecuredBulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::WriteSecured)
|
||||
}
|
||||
|
||||
/// Bulk `WriteSecured2` (timestamped) — see [`Session::write_secured_bulk`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::write_bulk`].
|
||||
pub async fn write_secured2_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
entries: Vec<WriteSecured2BulkEntry>,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
ensure_bulk_size("entries", entries.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::WriteSecured2Bulk,
|
||||
Payload::WriteSecured2Bulk(WriteSecured2BulkCommand {
|
||||
server_handle,
|
||||
entries,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_write_results(reply, BulkWriteReplyKind::WriteSecured2)
|
||||
}
|
||||
|
||||
/// Bulk `Read` — snapshot the current value for each requested tag.
|
||||
///
|
||||
/// MXAccess COM has no synchronous `Read`; the worker satisfies this by
|
||||
/// returning the most recent cached `OnDataChange` value when the tag is
|
||||
/// already advised (`was_cached = true`), or by taking a full AddItem +
|
||||
/// Advise + wait + UnAdvise + RemoveItem snapshot lifecycle otherwise.
|
||||
/// `timeout_ms == 0` lets the worker pick its default (1000 ms).
|
||||
/// Per-tag failures appear as `BulkReadResult` entries with
|
||||
/// `was_successful = false`; the call never errors on per-tag failure.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same conditions as [`Session::add_item_bulk`].
|
||||
pub async fn read_bulk(
|
||||
&self,
|
||||
server_handle: i32,
|
||||
tag_addresses: Vec<String>,
|
||||
timeout_ms: u32,
|
||||
) -> Result<Vec<BulkReadResult>, Error> {
|
||||
ensure_bulk_size("tag_addresses", tag_addresses.len())?;
|
||||
let reply = self
|
||||
.invoke(
|
||||
MxCommandKind::ReadBulk,
|
||||
Payload::ReadBulk(ReadBulkCommand {
|
||||
server_handle,
|
||||
tag_addresses,
|
||||
timeout_ms,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match reply.payload {
|
||||
Some(mx_command_reply::Payload::ReadBulk(reply)) => Ok(reply.results),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "ReadBulk reply did not carry a BulkReadReply payload".to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run MXAccess `Write` (single-value, no caller-supplied timestamp).
|
||||
///
|
||||
/// # Errors
|
||||
@@ -578,6 +722,39 @@ fn bulk_results(reply: MxCommandReply, kind: BulkReplyKind) -> Result<Vec<Subscr
|
||||
}
|
||||
}
|
||||
|
||||
enum BulkWriteReplyKind {
|
||||
Write,
|
||||
Write2,
|
||||
WriteSecured,
|
||||
WriteSecured2,
|
||||
}
|
||||
|
||||
fn bulk_write_results(
|
||||
reply: MxCommandReply,
|
||||
kind: BulkWriteReplyKind,
|
||||
) -> Result<Vec<BulkWriteResult>, Error> {
|
||||
match (reply.payload, kind) {
|
||||
(Some(mx_command_reply::Payload::WriteBulk(reply)), BulkWriteReplyKind::Write) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(Some(mx_command_reply::Payload::Write2Bulk(reply)), BulkWriteReplyKind::Write2) => {
|
||||
Ok(reply.results)
|
||||
}
|
||||
(
|
||||
Some(mx_command_reply::Payload::WriteSecuredBulk(reply)),
|
||||
BulkWriteReplyKind::WriteSecured,
|
||||
) => Ok(reply.results),
|
||||
(
|
||||
Some(mx_command_reply::Payload::WriteSecured2Bulk(reply)),
|
||||
BulkWriteReplyKind::WriteSecured2,
|
||||
) => Ok(reply.results),
|
||||
_ => Err(Error::MalformedReply {
|
||||
detail: "bulk write reply did not carry the expected BulkWriteReply payload"
|
||||
.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn int32_reply_value(value: &ProtoMxValue) -> Option<i32> {
|
||||
match value.kind.as_ref()? {
|
||||
crate::generated::mxaccess_gateway::v1::mx_value::Kind::Int32Value(value) => Some(*value),
|
||||
|
||||
@@ -11,14 +11,16 @@ use futures_util::StreamExt;
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::mx_access_gateway_server::{
|
||||
MxAccessGateway, MxAccessGatewayServer,
|
||||
};
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::mx_command;
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::mx_command_reply;
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
|
||||
use mxgateway_client::generated::mxaccess_gateway::v1::{
|
||||
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AddItemReply,
|
||||
BulkSubscribeReply, CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply,
|
||||
MxDataType, MxEvent, MxEventFamily, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
|
||||
OpenSessionReply, OpenSessionRequest, ProtocolStatus, ProtocolStatusCode,
|
||||
QueryActiveAlarmsRequest, SessionState, StreamEventsRequest, SubscribeResult,
|
||||
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
|
||||
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
|
||||
MxEventFamily, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue, OpenSessionReply,
|
||||
OpenSessionRequest, ProtocolStatus, ProtocolStatusCode, QueryActiveAlarmsRequest, SessionState,
|
||||
StreamEventsRequest, SubscribeResult, WriteBulkEntry,
|
||||
};
|
||||
use mxgateway_client::{
|
||||
ApiKey, ClientOptions, CommandError, Error, GatewayClient, MxStatus, MxValue as ClientMxValue,
|
||||
@@ -107,6 +109,61 @@ async fn subscribe_bulk_builds_one_bulk_command_and_returns_results() {
|
||||
assert_eq!(*last_command, Some(MxCommandKind::SubscribeBulk as i32));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_bulk_builds_one_bulk_command_and_returns_per_entry_results() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = client.session("session-fixture");
|
||||
|
||||
let results = session
|
||||
.write_bulk(
|
||||
12,
|
||||
vec![
|
||||
WriteBulkEntry {
|
||||
item_handle: 901,
|
||||
value: Some(int_value(11)),
|
||||
user_id: 5,
|
||||
},
|
||||
WriteBulkEntry {
|
||||
item_handle: 902,
|
||||
value: Some(int_value(22)),
|
||||
user_id: 5,
|
||||
},
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].was_successful);
|
||||
assert!(!results[1].was_successful);
|
||||
let last_command = state.last_command_kind.lock().await;
|
||||
assert_eq!(*last_command, Some(MxCommandKind::WriteBulk as i32));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_bulk_forwards_timeout_and_unpacks_cached_flag() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
let endpoint = spawn_fake_gateway(state.clone()).await;
|
||||
let client = GatewayClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
let session = client.session("session-fixture");
|
||||
|
||||
let results = session
|
||||
.read_bulk(12, vec!["Area001.Pump001.Speed".to_owned()], 750)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let entry = &results[0];
|
||||
assert!(entry.was_cached);
|
||||
assert_eq!(entry.value.as_ref().and_then(|v| v.kind.as_ref()), Some(&Kind::Int32Value(99)));
|
||||
assert_eq!(*state.last_read_bulk_timeout_ms.lock().await, Some(750));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
@@ -340,6 +397,7 @@ async fn connect_with_unreadable_ca_file_reports_invalid_endpoint() {
|
||||
struct FakeState {
|
||||
authorization: Mutex<Option<String>>,
|
||||
last_command_kind: Mutex<Option<i32>>,
|
||||
last_read_bulk_timeout_ms: Mutex<Option<u32>>,
|
||||
stream_dropped: Arc<AtomicBool>,
|
||||
emit_stream_fault: AtomicBool,
|
||||
}
|
||||
@@ -420,6 +478,70 @@ impl MxAccessGateway for FakeGateway {
|
||||
}));
|
||||
}
|
||||
|
||||
if kind == MxCommandKind::WriteBulk as i32 {
|
||||
// Echo one success and one failure so the test can assert the per-entry
|
||||
// shape and verify the call did not throw on per-entry failure.
|
||||
return Ok(Response::new(MxCommandReply {
|
||||
session_id: request.session_id,
|
||||
correlation_id: "fake-correlation".to_owned(),
|
||||
kind,
|
||||
protocol_status: Some(ok_status("command ok")),
|
||||
payload: Some(mx_command_reply::Payload::WriteBulk(BulkWriteReply {
|
||||
results: vec![
|
||||
BulkWriteResult {
|
||||
server_handle: 12,
|
||||
item_handle: 901,
|
||||
was_successful: true,
|
||||
hresult: None,
|
||||
statuses: vec![],
|
||||
error_message: String::new(),
|
||||
},
|
||||
BulkWriteResult {
|
||||
server_handle: 12,
|
||||
item_handle: 902,
|
||||
was_successful: false,
|
||||
hresult: None,
|
||||
statuses: vec![],
|
||||
error_message: "invalid handle".to_owned(),
|
||||
},
|
||||
],
|
||||
})),
|
||||
..MxCommandReply::default()
|
||||
}));
|
||||
}
|
||||
|
||||
if kind == MxCommandKind::ReadBulk as i32 {
|
||||
let read_command = request.command.as_ref().and_then(|c| {
|
||||
if let Some(mx_command::Payload::ReadBulk(ref r)) = c.payload {
|
||||
Some(r.timeout_ms)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
*self.state.last_read_bulk_timeout_ms.lock().await = read_command;
|
||||
return Ok(Response::new(MxCommandReply {
|
||||
session_id: request.session_id,
|
||||
correlation_id: "fake-correlation".to_owned(),
|
||||
kind,
|
||||
protocol_status: Some(ok_status("command ok")),
|
||||
payload: Some(mx_command_reply::Payload::ReadBulk(BulkReadReply {
|
||||
results: vec![BulkReadResult {
|
||||
server_handle: 12,
|
||||
tag_address: "Area001.Pump001.Speed".to_owned(),
|
||||
item_handle: 34,
|
||||
was_successful: true,
|
||||
was_cached: true,
|
||||
value: Some(int_value(99)),
|
||||
quality: 192,
|
||||
source_timestamp: None,
|
||||
statuses: vec![],
|
||||
error_message: String::new(),
|
||||
}],
|
||||
})),
|
||||
..MxCommandReply::default()
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Response::new(MxCommandReply {
|
||||
session_id: request.session_id,
|
||||
correlation_id: "fake-correlation".to_owned(),
|
||||
@@ -527,6 +649,15 @@ async fn spawn_fake_gateway(state: Arc<FakeState>) -> String {
|
||||
format!("http://{address}")
|
||||
}
|
||||
|
||||
fn int_value(value: i32) -> MxValue {
|
||||
MxValue {
|
||||
data_type: MxDataType::Integer as i32,
|
||||
variant_type: "VT_I4".to_owned(),
|
||||
kind: Some(Kind::Int32Value(value)),
|
||||
..MxValue::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_status(message: &str) -> ProtocolStatus {
|
||||
ProtocolStatus {
|
||||
code: ProtocolStatusCode::Ok as i32,
|
||||
|
||||
@@ -199,6 +199,57 @@ and failure behavior are easy to compare against direct MXAccess.
|
||||
|
||||
Batch tag registration can be added later if measured setup latency requires it.
|
||||
|
||||
## Bulk Command Family
|
||||
|
||||
Decision: the gateway exposes a fixed set of *bulk* command kinds —
|
||||
`AddItemBulk`, `AdviseItemBulk`, `RemoveItemBulk`, `UnAdviseItemBulk`,
|
||||
`SubscribeBulk`, `UnsubscribeBulk`, `WriteBulk`, `Write2Bulk`,
|
||||
`WriteSecuredBulk`, `WriteSecured2Bulk`, `ReadBulk` — that carry a list of
|
||||
entries in one round-trip and return one per-entry result. Each command kind
|
||||
runs the corresponding single-item MXAccess COM call sequentially on the
|
||||
worker STA; per-entry failures populate `was_successful = false` with the
|
||||
underlying HRESULT and never throw. There is no transactional / fail-fast
|
||||
semantic — bulk here means "one round-trip, per-entry results", not
|
||||
"atomic".
|
||||
|
||||
Rationale: MXAccess COM itself has no native bulk API for any of these
|
||||
operations. Surfacing the per-entry result list keeps parity transparent —
|
||||
the caller sees the same per-item HRESULT they would see calling MXAccess
|
||||
N times directly — while the bulk shape collapses the gateway/IPC overhead
|
||||
to one round-trip per batch and lets the worker keep the STA hot.
|
||||
|
||||
`ReadBulk` is the only bulk command without a 1:1 MXAccess analogue. Two
|
||||
choices were considered:
|
||||
|
||||
1. **Cache-then-snapshot** (chosen): when a requested tag is already in the
|
||||
session's item registry AND advised, the worker returns the last cached
|
||||
`OnDataChange` value without touching the subscription
|
||||
(`was_cached = true`). Otherwise it takes the full `AddItem + Advise +
|
||||
wait-for-first-OnDataChange + UnAdvise + RemoveItem` lifecycle itself
|
||||
(`was_cached = false`) and leaves the session exactly as it was before
|
||||
the call. The cache lives on a per-session `MxAccessValueCache`,
|
||||
populated by `MxAccessBaseEventSink` on every `OnDataChange` after the
|
||||
event clears the outbound queue.
|
||||
|
||||
2. **Always-snapshot**: take the AddItem-through-RemoveItem lifecycle for
|
||||
every requested tag. Cleaner conceptually but pays the full lifecycle
|
||||
cost on every call and would interfere with existing subscriptions if
|
||||
MXAccess reuses item handles.
|
||||
|
||||
The chosen behavior matches what callers actually want from "current
|
||||
value" — a free read of an already-streaming tag, and a one-shot snapshot
|
||||
otherwise — and never disturbs subscriptions the caller did not create.
|
||||
The decision intentionally does NOT synthesize an `OnDataChange` event
|
||||
from the snapshot path: the snapshot value reaches the caller through
|
||||
`ReadBulk`'s reply payload only, not through the event stream. This
|
||||
preserves the "Don't synthesize events" rule that scopes the rest of the
|
||||
worker.
|
||||
|
||||
`ReadBulk`'s wait loop pumps Windows messages on the worker STA
|
||||
(`StaRuntime.PumpPendingMessages`) on every poll iteration so the inbound
|
||||
MXAccess COM event can dispatch while the bulk executor still holds the
|
||||
thread — without the pump the OnDataChange would never deliver.
|
||||
|
||||
## Graceful Worker Shutdown
|
||||
|
||||
Decision: best-effort cleanup before COM release.
|
||||
|
||||
+38
@@ -283,6 +283,44 @@ Core commands:
|
||||
- `AuthenticateUser`
|
||||
- `ArchestrAUserToId`
|
||||
|
||||
Bulk variants (single gRPC round-trip carries the full list, the worker
|
||||
runs the per-item MXAccess calls sequentially on its STA, and the reply
|
||||
returns one result per requested entry — per-entry failures populate
|
||||
`was_successful = false` + `error_message` and never throw):
|
||||
|
||||
- `AddItemBulk` — `repeated string tag_addresses` → `BulkSubscribeReply`.
|
||||
- `AdviseItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`.
|
||||
- `RemoveItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`.
|
||||
- `UnAdviseItemBulk` — `repeated int32 item_handles` → `BulkSubscribeReply`.
|
||||
- `SubscribeBulk` — `repeated string tag_addresses` (AddItem + Advise per
|
||||
entry, with cleanup on Advise failure) → `BulkSubscribeReply`.
|
||||
- `UnsubscribeBulk` — `repeated int32 item_handles` (UnAdvise + RemoveItem
|
||||
per entry, with independent error tracking) → `BulkSubscribeReply`.
|
||||
- `WriteBulk` — `repeated WriteBulkEntry` (each `{item_handle, value, user_id}`)
|
||||
→ `BulkWriteReply` (`repeated BulkWriteResult`). Required scope: `invoke:write`.
|
||||
- `Write2Bulk` — `repeated Write2BulkEntry` (each adds `timestamp_value`) →
|
||||
`BulkWriteReply`. Required scope: `invoke:write`.
|
||||
- `WriteSecuredBulk` — `repeated WriteSecuredBulkEntry` (each
|
||||
`{item_handle, current_user_id, verifier_user_id, value}`) → `BulkWriteReply`.
|
||||
Required scope: `invoke:secure`. Same redaction rules as single-item
|
||||
`WriteSecured`: per-entry `value` must never reach logs unless an explicit
|
||||
redacted value-logging path is enabled.
|
||||
- `WriteSecured2Bulk` — `repeated WriteSecured2BulkEntry` (each adds
|
||||
`timestamp_value`) → `BulkWriteReply`. Required scope: `invoke:secure`.
|
||||
- `ReadBulk` — `repeated string tag_addresses` + `uint32 timeout_ms` →
|
||||
`BulkReadReply` (`repeated BulkReadResult`). MXAccess COM has no
|
||||
synchronous `Read`; the worker satisfies this command by returning the
|
||||
last cached `OnDataChange` payload when the requested tag is already
|
||||
advised (`was_cached = true`, no subscription side-effects), or by
|
||||
taking a full `AddItem` + `Advise` + wait-for-first-OnDataChange +
|
||||
`UnAdvise` + `RemoveItem` snapshot lifecycle when no live subscription
|
||||
exists (`was_cached = false`). Per-tag timeouts surface as
|
||||
`was_successful = false` rather than throwing. The cache lives on the
|
||||
worker's `MxAccessValueCache`, populated by `MxAccessBaseEventSink` on
|
||||
every `OnDataChange` after the event clears the outbound queue.
|
||||
Required scope: `invoke:read`. `timeout_ms == 0` uses the worker's
|
||||
default (1000 ms).
|
||||
|
||||
Optional diagnostics:
|
||||
|
||||
- `Ping`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -100,6 +100,11 @@ message MxCommand {
|
||||
AcknowledgeAlarmCommand acknowledge_alarm_command = 36;
|
||||
QueryActiveAlarmsCommand query_active_alarms_command = 37;
|
||||
AcknowledgeAlarmByNameCommand acknowledge_alarm_by_name_command = 38;
|
||||
WriteBulkCommand write_bulk = 39;
|
||||
Write2BulkCommand write2_bulk = 40;
|
||||
WriteSecuredBulkCommand write_secured_bulk = 41;
|
||||
WriteSecured2BulkCommand write_secured2_bulk = 42;
|
||||
ReadBulkCommand read_bulk = 43;
|
||||
PingCommand ping = 100;
|
||||
GetSessionStateCommand get_session_state = 101;
|
||||
GetWorkerInfoCommand get_worker_info = 102;
|
||||
@@ -139,6 +144,11 @@ enum MxCommandKind {
|
||||
MX_COMMAND_KIND_ACKNOWLEDGE_ALARM = 27;
|
||||
MX_COMMAND_KIND_QUERY_ACTIVE_ALARMS = 28;
|
||||
MX_COMMAND_KIND_ACKNOWLEDGE_ALARM_BY_NAME = 29;
|
||||
MX_COMMAND_KIND_WRITE_BULK = 30;
|
||||
MX_COMMAND_KIND_WRITE2_BULK = 31;
|
||||
MX_COMMAND_KIND_WRITE_SECURED_BULK = 32;
|
||||
MX_COMMAND_KIND_WRITE_SECURED2_BULK = 33;
|
||||
MX_COMMAND_KIND_READ_BULK = 34;
|
||||
MX_COMMAND_KIND_PING = 100;
|
||||
MX_COMMAND_KIND_GET_SESSION_STATE = 101;
|
||||
MX_COMMAND_KIND_GET_WORKER_INFO = 102;
|
||||
@@ -342,6 +352,82 @@ message UnsubscribeBulkCommand {
|
||||
repeated int32 item_handles = 2;
|
||||
}
|
||||
|
||||
// Bulk Write — sequential MXAccess Write per entry, on the worker's STA.
|
||||
// MXAccess has no native bulk write; each entry round-trips through the same
|
||||
// single-item Write path the gateway uses today. Per-item failures appear as
|
||||
// BulkWriteResult entries with `was_successful = false` and never throw.
|
||||
message WriteBulkCommand {
|
||||
int32 server_handle = 1;
|
||||
repeated WriteBulkEntry entries = 2;
|
||||
}
|
||||
|
||||
message WriteBulkEntry {
|
||||
int32 item_handle = 1;
|
||||
MxValue value = 2;
|
||||
int32 user_id = 3;
|
||||
}
|
||||
|
||||
// Bulk Write2 — sequential MXAccess Write2 (timestamped) per entry.
|
||||
message Write2BulkCommand {
|
||||
int32 server_handle = 1;
|
||||
repeated Write2BulkEntry entries = 2;
|
||||
}
|
||||
|
||||
message Write2BulkEntry {
|
||||
int32 item_handle = 1;
|
||||
MxValue value = 2;
|
||||
MxValue timestamp_value = 3;
|
||||
int32 user_id = 4;
|
||||
}
|
||||
|
||||
// Bulk WriteSecured — sequential MXAccess WriteSecured per entry.
|
||||
// Credential-sensitive values (`value`) MUST be kept out of logs, metrics
|
||||
// labels, command lines, and diagnostics — same redaction rules as the
|
||||
// single-item WriteSecured contract.
|
||||
message WriteSecuredBulkCommand {
|
||||
int32 server_handle = 1;
|
||||
repeated WriteSecuredBulkEntry entries = 2;
|
||||
}
|
||||
|
||||
message WriteSecuredBulkEntry {
|
||||
int32 item_handle = 1;
|
||||
int32 current_user_id = 2;
|
||||
int32 verifier_user_id = 3;
|
||||
MxValue value = 4;
|
||||
}
|
||||
|
||||
// Bulk WriteSecured2 — sequential MXAccess WriteSecured2 (timestamped) per
|
||||
// entry. Same redaction rules apply.
|
||||
message WriteSecured2BulkCommand {
|
||||
int32 server_handle = 1;
|
||||
repeated WriteSecured2BulkEntry entries = 2;
|
||||
}
|
||||
|
||||
message WriteSecured2BulkEntry {
|
||||
int32 item_handle = 1;
|
||||
int32 current_user_id = 2;
|
||||
int32 verifier_user_id = 3;
|
||||
MxValue value = 4;
|
||||
MxValue timestamp_value = 5;
|
||||
}
|
||||
|
||||
// Bulk Read — snapshot the current value for each requested tag. MXAccess COM
|
||||
// has no synchronous Read; the worker implements ReadBulk as:
|
||||
// - If the tag is already in the session's item registry AND that item is
|
||||
// currently advised AND the worker has a cached OnDataChange for it, the
|
||||
// reply returns the cached value WITHOUT modifying the existing
|
||||
// subscription (was_cached = true).
|
||||
// - Otherwise the worker takes the snapshot lifecycle itself: AddItem +
|
||||
// Advise, wait up to `timeout_ms` for the first OnDataChange, then
|
||||
// UnAdvise + RemoveItem before returning. The session is left exactly
|
||||
// as it was before the call (was_cached = false).
|
||||
// `timeout_ms == 0` uses the gateway-configured default (1000 ms).
|
||||
message ReadBulkCommand {
|
||||
int32 server_handle = 1;
|
||||
repeated string tag_addresses = 2;
|
||||
uint32 timeout_ms = 3;
|
||||
}
|
||||
|
||||
message PingCommand {
|
||||
string message = 1;
|
||||
}
|
||||
@@ -399,6 +485,11 @@ message MxCommandReply {
|
||||
// mirrors AcknowledgeAlarmReplyPayload.native_status and is preferred.
|
||||
AcknowledgeAlarmReplyPayload acknowledge_alarm = 34;
|
||||
QueryActiveAlarmsReplyPayload query_active_alarms = 35;
|
||||
BulkWriteReply write_bulk = 36;
|
||||
BulkWriteReply write2_bulk = 37;
|
||||
BulkWriteReply write_secured_bulk = 38;
|
||||
BulkWriteReply write_secured2_bulk = 39;
|
||||
BulkReadReply read_bulk = 40;
|
||||
SessionStateReply session_state = 100;
|
||||
WorkerInfoReply worker_info = 101;
|
||||
DrainEventsReply drain_events = 102;
|
||||
@@ -449,6 +540,45 @@ message BulkSubscribeReply {
|
||||
repeated SubscribeResult results = 1;
|
||||
}
|
||||
|
||||
// Per-item result for the four bulk write families. `item_handle` mirrors the
|
||||
// request entry's item_handle so callers can correlate inputs to outputs even
|
||||
// when the gateway's tag-allowlist filter dropped some entries before reaching
|
||||
// the worker. Per-item failures populate `error_message` + `hresult` and never
|
||||
// raise — callers iterate and inspect each entry.
|
||||
message BulkWriteResult {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
bool was_successful = 3;
|
||||
optional int32 hresult = 4;
|
||||
repeated MxStatusProxy statuses = 5;
|
||||
string error_message = 6;
|
||||
}
|
||||
|
||||
message BulkWriteReply {
|
||||
repeated BulkWriteResult results = 1;
|
||||
}
|
||||
|
||||
// Per-tag result for ReadBulk. `was_cached` is true when the value came from
|
||||
// an existing live subscription's last OnDataChange (the worker did not touch
|
||||
// the subscription); false when the worker took the AddItem + Advise + wait +
|
||||
// UnAdvise + RemoveItem snapshot lifecycle itself.
|
||||
message BulkReadResult {
|
||||
int32 server_handle = 1;
|
||||
string tag_address = 2;
|
||||
int32 item_handle = 3;
|
||||
bool was_successful = 4;
|
||||
bool was_cached = 5;
|
||||
MxValue value = 6;
|
||||
int32 quality = 7;
|
||||
google.protobuf.Timestamp source_timestamp = 8;
|
||||
repeated MxStatusProxy statuses = 9;
|
||||
string error_message = 10;
|
||||
}
|
||||
|
||||
message BulkReadReply {
|
||||
repeated BulkReadResult results = 1;
|
||||
}
|
||||
|
||||
message SessionStateReply {
|
||||
SessionState state = 1;
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ public sealed class MxAccessGatewayService(
|
||||
MxCommand commandToInvoke = bulkConstraintPlan?.Command ?? command;
|
||||
if (bulkConstraintPlan is { HasAllowedItems: false })
|
||||
{
|
||||
return CreateDeniedBulkReply(request, bulkConstraintPlan);
|
||||
return bulkConstraintPlan.CreateDeniedReply(request);
|
||||
}
|
||||
|
||||
MxCommandRequest invokeRequest = request.Clone();
|
||||
@@ -122,7 +122,7 @@ public sealed class MxAccessGatewayService(
|
||||
MxCommandReply publicReply = mapper.MapCommandReply(workerReply);
|
||||
if (bulkConstraintPlan is not null)
|
||||
{
|
||||
publicReply = MergeDeniedBulkResults(publicReply, command.Kind, bulkConstraintPlan);
|
||||
publicReply = bulkConstraintPlan.MergeDeniedInto(publicReply);
|
||||
}
|
||||
|
||||
session.TrackCommandReply(commandToInvoke, publicReply);
|
||||
@@ -304,6 +304,54 @@ public sealed class MxAccessGatewayService(
|
||||
command.AdviseItemBulk.ItemHandles,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.ReadBulk:
|
||||
return await FilterReadBulkAsync(
|
||||
identity,
|
||||
command,
|
||||
command.ReadBulk.ServerHandle,
|
||||
command.ReadBulk.TagAddresses,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.WriteBulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.WriteBulk.ServerHandle,
|
||||
command.WriteBulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.Write2Bulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.Write2Bulk.ServerHandle,
|
||||
command.Write2Bulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.WriteSecuredBulk.ServerHandle,
|
||||
command.WriteSecuredBulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
return await FilterWriteBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.WriteSecured2Bulk.ServerHandle,
|
||||
command.WriteSecured2Bulk.Entries,
|
||||
entry => entry.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.Write:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
@@ -438,7 +486,136 @@ public sealed class MxAccessGatewayService(
|
||||
filtered.SubscribeBulk.TagAddresses.Add(allowed);
|
||||
}
|
||||
|
||||
return new BulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
return new SubscribeBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterReadBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Mirrors FilterTagBulkAsync but produces BulkReadResult denial entries
|
||||
// so the reply payload merges into BulkReadReply.Results, not
|
||||
// BulkSubscribeReply.Results.
|
||||
Dictionary<int, BulkReadResult> denied = [];
|
||||
List<string> allowed = [];
|
||||
for (int index = 0; index < tagAddresses.Count; index++)
|
||||
{
|
||||
string tagAddress = tagAddresses[index];
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(tagAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new BulkReadResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TagAddress = tagAddress,
|
||||
WasSuccessful = false,
|
||||
WasCached = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
filtered.ReadBulk.TagAddresses.Clear();
|
||||
filtered.ReadBulk.TagAddresses.Add(allowed);
|
||||
|
||||
return new ReadBulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterWriteBulkAsync<TEntry>(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
Google.Protobuf.Collections.RepeatedField<TEntry> entries,
|
||||
Func<TEntry, int> getItemHandle,
|
||||
CancellationToken cancellationToken) where TEntry : class
|
||||
{
|
||||
// The four bulk-write families each carry a different per-entry message
|
||||
// shape (WriteBulkEntry / Write2BulkEntry / WriteSecuredBulkEntry /
|
||||
// WriteSecured2BulkEntry), but the constraint check itself is identical
|
||||
// — "is this caller allowed to write to this server+item handle?".
|
||||
// Parameterising on TEntry + getItemHandle keeps a single filter
|
||||
// routine for all four and avoids duplicating CheckWriteHandleAsync
|
||||
// calls.
|
||||
Dictionary<int, BulkWriteResult> denied = [];
|
||||
List<TEntry> allowed = [];
|
||||
for (int index = 0; index < entries.Count; index++)
|
||||
{
|
||||
TEntry entry = entries[index];
|
||||
int itemHandle = getItemHandle(entry);
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(
|
||||
identity,
|
||||
command.Kind.ToString(),
|
||||
itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
failure,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new BulkWriteResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
ReplaceWriteBulkEntries(filtered, allowed);
|
||||
return new WriteBulkConstraintPlan(filtered, entries.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private static void ReplaceWriteBulkEntries<TEntry>(MxCommand command, IReadOnlyList<TEntry> allowed)
|
||||
where TEntry : class
|
||||
{
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.WriteBulk:
|
||||
command.WriteBulk.Entries.Clear();
|
||||
command.WriteBulk.Entries.Add((IEnumerable<WriteBulkEntry>)allowed);
|
||||
break;
|
||||
case MxCommandKind.Write2Bulk:
|
||||
command.Write2Bulk.Entries.Clear();
|
||||
command.Write2Bulk.Entries.Add((IEnumerable<Write2BulkEntry>)allowed);
|
||||
break;
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
command.WriteSecuredBulk.Entries.Clear();
|
||||
command.WriteSecuredBulk.Entries.Add((IEnumerable<WriteSecuredBulkEntry>)allowed);
|
||||
break;
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
command.WriteSecured2Bulk.Entries.Clear();
|
||||
command.WriteSecured2Bulk.Entries.Add((IEnumerable<WriteSecured2BulkEntry>)allowed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterHandleBulkAsync(
|
||||
@@ -483,90 +660,221 @@ public sealed class MxAccessGatewayService(
|
||||
filtered.AdviseItemBulk.ItemHandles.Clear();
|
||||
filtered.AdviseItemBulk.ItemHandles.Add(allowed);
|
||||
|
||||
return new BulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0);
|
||||
return new SubscribeBulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateDeniedBulkReply(
|
||||
MxCommandRequest request,
|
||||
BulkConstraintPlan plan)
|
||||
/// <summary>
|
||||
/// Polymorphic constraint plan returned from <see cref="ApplyConstraintsAsync"/>.
|
||||
/// Each concrete subtype is keyed to a specific bulk-reply shape — the
|
||||
/// SubscribeResult-based AddItem/Advise/Subscribe family, the
|
||||
/// BulkWriteResult-based Write* bulk family, and the BulkReadResult-based
|
||||
/// ReadBulk command. Subtypes own their own merge / denied-reply build
|
||||
/// logic so the Invoke dispatch site never branches on reply shape.
|
||||
/// </summary>
|
||||
private abstract record BulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
bool HasAllowedItems)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
/// <summary>Builds a reply containing only the denied entries (used when no items survived filtering).</summary>
|
||||
public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request);
|
||||
|
||||
/// <summary>Splices denied entries back into the worker's allowed-only reply in original-index order.</summary>
|
||||
public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply);
|
||||
}
|
||||
|
||||
private sealed record SubscribeBulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, SubscribeResult> DeniedResults,
|
||||
bool HasAllowedItems)
|
||||
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
|
||||
{
|
||||
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetBulkPayload(reply, request.Command.Kind, BuildMergedBulkReply(new BulkSubscribeReply(), plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply MergeDeniedBulkResults(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
BulkSubscribeReply allowed = GetBulkPayload(reply, commandKind) ?? new BulkSubscribeReply();
|
||||
SetBulkPayload(reply, commandKind, BuildMergedBulkReply(allowed, plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply BuildMergedBulkReply(
|
||||
BulkSubscribeReply allowed,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
Queue<SubscribeResult> allowedResults = new(allowed.Results);
|
||||
BulkSubscribeReply merged = new();
|
||||
for (int index = 0; index < plan.OriginalCount; index++)
|
||||
{
|
||||
if (plan.DeniedResults.TryGetValue(index, out SubscribeResult? denied))
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetPayload(reply, BuildMerged(new BulkSubscribeReply()));
|
||||
return reply;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
|
||||
{
|
||||
BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply();
|
||||
SetPayload(reply, BuildMerged(allowed));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply? GetBulkPayload(MxCommandReply reply, MxCommandKind commandKind)
|
||||
{
|
||||
return commandKind switch
|
||||
private BulkSubscribeReply BuildMerged(BulkSubscribeReply allowed)
|
||||
{
|
||||
Queue<SubscribeResult> allowedResults = new(allowed.Results);
|
||||
BulkSubscribeReply merged = new();
|
||||
for (int index = 0; index < OriginalCount; index++)
|
||||
{
|
||||
if (DeniedResults.TryGetValue(index, out SubscribeResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private BulkSubscribeReply? GetPayload(MxCommandReply reply) => Command.Kind switch
|
||||
{
|
||||
MxCommandKind.AddItemBulk => reply.AddItemBulk,
|
||||
MxCommandKind.AdviseItemBulk => reply.AdviseItemBulk,
|
||||
MxCommandKind.SubscribeBulk => reply.SubscribeBulk,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static void SetBulkPayload(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkSubscribeReply payload)
|
||||
{
|
||||
switch (commandKind)
|
||||
private void SetPayload(MxCommandReply reply, BulkSubscribeReply payload)
|
||||
{
|
||||
case MxCommandKind.AddItemBulk:
|
||||
reply.AddItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
reply.AdviseItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
reply.SubscribeBulk = payload;
|
||||
break;
|
||||
switch (Command.Kind)
|
||||
{
|
||||
case MxCommandKind.AddItemBulk:
|
||||
reply.AddItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
reply.AdviseItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
reply.SubscribeBulk = payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BulkConstraintPlan(
|
||||
private sealed record WriteBulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, SubscribeResult> DeniedResults,
|
||||
bool HasAllowedItems);
|
||||
IReadOnlyDictionary<int, BulkWriteResult> DeniedResults,
|
||||
bool HasAllowedItems)
|
||||
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
|
||||
{
|
||||
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetPayload(reply, BuildMerged(new BulkWriteReply()));
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
|
||||
{
|
||||
BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply();
|
||||
SetPayload(reply, BuildMerged(allowed));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private BulkWriteReply BuildMerged(BulkWriteReply allowed)
|
||||
{
|
||||
Queue<BulkWriteResult> allowedResults = new(allowed.Results);
|
||||
BulkWriteReply merged = new();
|
||||
for (int index = 0; index < OriginalCount; index++)
|
||||
{
|
||||
if (DeniedResults.TryGetValue(index, out BulkWriteResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out BulkWriteResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private BulkWriteReply? GetPayload(MxCommandReply reply) => Command.Kind switch
|
||||
{
|
||||
MxCommandKind.WriteBulk => reply.WriteBulk,
|
||||
MxCommandKind.Write2Bulk => reply.Write2Bulk,
|
||||
MxCommandKind.WriteSecuredBulk => reply.WriteSecuredBulk,
|
||||
MxCommandKind.WriteSecured2Bulk => reply.WriteSecured2Bulk,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private void SetPayload(MxCommandReply reply, BulkWriteReply payload)
|
||||
{
|
||||
switch (Command.Kind)
|
||||
{
|
||||
case MxCommandKind.WriteBulk:
|
||||
reply.WriteBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.Write2Bulk:
|
||||
reply.Write2Bulk = payload;
|
||||
break;
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
reply.WriteSecuredBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
reply.WriteSecured2Bulk = payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ReadBulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, BulkReadResult> DeniedResults,
|
||||
bool HasAllowedItems)
|
||||
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
|
||||
{
|
||||
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
reply.ReadBulk = BuildMerged(new BulkReadReply());
|
||||
return reply;
|
||||
}
|
||||
|
||||
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
|
||||
{
|
||||
BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply();
|
||||
reply.ReadBulk = BuildMerged(allowed);
|
||||
return reply;
|
||||
}
|
||||
|
||||
private BulkReadReply BuildMerged(BulkReadReply allowed)
|
||||
{
|
||||
Queue<BulkReadResult> allowedResults = new(allowed.Results);
|
||||
BulkReadReply merged = new();
|
||||
for (int index = 0; index < OriginalCount; index++)
|
||||
{
|
||||
if (DeniedResults.TryGetValue(index, out BulkReadResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out BulkReadResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
private RpcException MapException(Exception exception)
|
||||
{
|
||||
|
||||
@@ -99,6 +99,11 @@ public sealed class MxAccessGrpcRequestValidator
|
||||
MxCommandKind.UnAdviseItemBulk => MxCommand.PayloadOneofCase.UnAdviseItemBulk,
|
||||
MxCommandKind.SubscribeBulk => MxCommand.PayloadOneofCase.SubscribeBulk,
|
||||
MxCommandKind.UnsubscribeBulk => MxCommand.PayloadOneofCase.UnsubscribeBulk,
|
||||
MxCommandKind.WriteBulk => MxCommand.PayloadOneofCase.WriteBulk,
|
||||
MxCommandKind.Write2Bulk => MxCommand.PayloadOneofCase.Write2Bulk,
|
||||
MxCommandKind.WriteSecuredBulk => MxCommand.PayloadOneofCase.WriteSecuredBulk,
|
||||
MxCommandKind.WriteSecured2Bulk => MxCommand.PayloadOneofCase.WriteSecured2Bulk,
|
||||
MxCommandKind.ReadBulk => MxCommand.PayloadOneofCase.ReadBulk,
|
||||
MxCommandKind.Ping => MxCommand.PayloadOneofCase.Ping,
|
||||
MxCommandKind.GetSessionState => MxCommand.PayloadOneofCase.GetSessionState,
|
||||
MxCommandKind.GetWorkerInfo => MxCommand.PayloadOneofCase.GetWorkerInfo,
|
||||
|
||||
@@ -31,10 +31,14 @@ public sealed class GatewayGrpcScopeResolver
|
||||
return kind switch
|
||||
{
|
||||
MxCommandKind.Write or
|
||||
MxCommandKind.Write2 => GatewayScopes.InvokeWrite,
|
||||
MxCommandKind.Write2 or
|
||||
MxCommandKind.WriteBulk or
|
||||
MxCommandKind.Write2Bulk => GatewayScopes.InvokeWrite,
|
||||
|
||||
MxCommandKind.WriteSecured or
|
||||
MxCommandKind.WriteSecured2 or
|
||||
MxCommandKind.WriteSecuredBulk or
|
||||
MxCommandKind.WriteSecured2Bulk or
|
||||
MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure,
|
||||
|
||||
MxCommandKind.ArchestraUserToId or
|
||||
|
||||
@@ -590,6 +590,116 @@ public sealed class GatewaySession
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a bulk Write command for the specified server and per-item entries.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteBulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteBulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
WriteBulk = bulkCommand,
|
||||
},
|
||||
reply => reply.WriteBulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Executes a bulk Write2 (timestamped) command.</summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<Write2BulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
Write2BulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = bulkCommand,
|
||||
},
|
||||
reply => reply.Write2Bulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Executes a bulk WriteSecured command.</summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecuredBulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = bulkCommand,
|
||||
},
|
||||
reply => reply.WriteSecuredBulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Executes a bulk WriteSecured2 command.</summary>
|
||||
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
|
||||
WriteSecured2BulkCommand bulkCommand = new() { ServerHandle = serverHandle };
|
||||
bulkCommand.Entries.Add(entries);
|
||||
return InvokeBulkWriteAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = bulkCommand,
|
||||
},
|
||||
reply => reply.WriteSecured2Bulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a bulk Read command — see <c>ReadBulkCommand</c>'s doc
|
||||
/// comment in the .proto for the cached-vs-snapshot semantics.
|
||||
/// </summary>
|
||||
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||
|
||||
ReadBulkCommand bulkCommand = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TimeoutMs = timeout <= TimeSpan.Zero ? 0u : (uint)Math.Min(timeout.TotalMilliseconds, uint.MaxValue),
|
||||
};
|
||||
bulkCommand.TagAddresses.Add(tagAddresses);
|
||||
return InvokeBulkReadAsync(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ReadBulk = bulkCommand,
|
||||
},
|
||||
reply => reply.ReadBulk,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads events from the worker as an asynchronous enumerable stream.
|
||||
/// </summary>
|
||||
@@ -690,6 +800,36 @@ public sealed class GatewaySession
|
||||
MxCommand command,
|
||||
Func<MxCommandReply, BulkSubscribeReply?> payloadAccessor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BulkWriteResult>> InvokeBulkWriteAsync(
|
||||
MxCommand command,
|
||||
Func<MxCommandReply, BulkWriteReply?> payloadAccessor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<BulkReadResult>> InvokeBulkReadAsync(
|
||||
MxCommand command,
|
||||
Func<MxCommandReply, BulkReadReply?> payloadAccessor,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
MxCommandReply reply = await InvokeBulkInternalAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
}
|
||||
|
||||
// Single round-trip + protocol-status check shared by every bulk variant.
|
||||
// Callers project the typed reply payload out via their own accessor — the
|
||||
// outer envelope handling is identical across SubscribeResult-based bulks,
|
||||
// BulkWriteResult-based writes, and BulkReadResult-based reads.
|
||||
private async Task<MxCommandReply> InvokeBulkInternalAsync(
|
||||
MxCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
WorkerCommandReply workerReply = await InvokeAsync(
|
||||
new WorkerCommand { Command = command },
|
||||
@@ -712,7 +852,7 @@ public sealed class GatewaySession
|
||||
string.IsNullOrWhiteSpace(message) ? "Bulk MXAccess command failed." : message);
|
||||
}
|
||||
|
||||
return payloadAccessor(reply)?.Results.ToArray() ?? [];
|
||||
return reply;
|
||||
}
|
||||
|
||||
private IWorkerClient GetReadyWorkerClient()
|
||||
|
||||
@@ -167,6 +167,119 @@ public sealed class SessionManagerTests
|
||||
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.SubscribeBulk.TagAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewaySessionWriteBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
FakeWorkerClient workerClient = new()
|
||||
{
|
||||
InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = "session-1",
|
||||
CorrelationId = "correlation-1",
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
WriteBulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult
|
||||
{
|
||||
ServerHandle = 12,
|
||||
ItemHandle = 901,
|
||||
WasSuccessful = true,
|
||||
},
|
||||
new BulkWriteResult
|
||||
{
|
||||
ServerHandle = 12,
|
||||
ItemHandle = 902,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "MXAccess invalid handle",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
|
||||
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||
|
||||
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteBulkEntry
|
||||
{
|
||||
ItemHandle = 901,
|
||||
UserId = 5,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 },
|
||||
},
|
||||
new WriteBulkEntry
|
||||
{
|
||||
ItemHandle = 902,
|
||||
UserId = 5,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 },
|
||||
},
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results[0].WasSuccessful);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
Assert.Equal(MxCommandKind.WriteBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewaySessionReadBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
FakeWorkerClient workerClient = new()
|
||||
{
|
||||
InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = "session-1",
|
||||
CorrelationId = "correlation-1",
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
ReadBulk = new BulkReadReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 12,
|
||||
TagAddress = "Galaxy.Tag.Value",
|
||||
ItemHandle = 512,
|
||||
WasSuccessful = true,
|
||||
WasCached = true,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 42 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
|
||||
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||
|
||||
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
|
||||
12,
|
||||
["Galaxy.Tag.Value"],
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
CancellationToken.None);
|
||||
|
||||
BulkReadResult result = Assert.Single(results);
|
||||
Assert.True(result.WasSuccessful);
|
||||
Assert.True(result.WasCached);
|
||||
Assert.Equal(42, result.Value.Int32Value);
|
||||
Assert.Equal(MxCommandKind.ReadBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(["Galaxy.Tag.Value"], workerClient.LastCommand?.Command.ReadBulk.TagAddresses);
|
||||
Assert.Equal(500u, workerClient.LastCommand?.Command.ReadBulk.TimeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invoking a command on a faulted session rejects the command.</summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionFaulted_RejectsCommand()
|
||||
|
||||
@@ -39,6 +39,11 @@ public sealed class GatewayGrpcScopeResolverTests
|
||||
[InlineData(MxCommandKind.Write2, GatewayScopes.InvokeWrite)]
|
||||
[InlineData(MxCommandKind.WriteSecured, GatewayScopes.InvokeSecure)]
|
||||
[InlineData(MxCommandKind.WriteSecured2, GatewayScopes.InvokeSecure)]
|
||||
[InlineData(MxCommandKind.WriteBulk, GatewayScopes.InvokeWrite)]
|
||||
[InlineData(MxCommandKind.Write2Bulk, GatewayScopes.InvokeWrite)]
|
||||
[InlineData(MxCommandKind.WriteSecuredBulk, GatewayScopes.InvokeSecure)]
|
||||
[InlineData(MxCommandKind.WriteSecured2Bulk, GatewayScopes.InvokeSecure)]
|
||||
[InlineData(MxCommandKind.ReadBulk, GatewayScopes.InvokeRead)]
|
||||
[InlineData(MxCommandKind.AuthenticateUser, GatewayScopes.InvokeSecure)]
|
||||
[InlineData(MxCommandKind.ArchestraUserToId, GatewayScopes.MetadataRead)]
|
||||
[InlineData(MxCommandKind.GetSessionState, GatewayScopes.MetadataRead)]
|
||||
|
||||
@@ -336,6 +336,9 @@ public sealed class AlarmCommandExecutorTests
|
||||
{
|
||||
// Walk to the private constructor via reflection — the public
|
||||
// factory MxAccessSession.Create(...) requires a real COM object.
|
||||
// Signature mirrors MxAccessSession's private ctor; the
|
||||
// MxAccessValueCache slot was added when ReadBulk gained the
|
||||
// cached-vs-snapshot fork.
|
||||
System.Reflection.ConstructorInfo? ctor = typeof(MxAccessSession)
|
||||
.GetConstructor(
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
|
||||
@@ -346,6 +349,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
typeof(IMxAccessServer),
|
||||
typeof(IMxAccessEventSink),
|
||||
typeof(MxAccessHandleRegistry),
|
||||
typeof(MxAccessValueCache),
|
||||
typeof(int),
|
||||
},
|
||||
modifiers: null);
|
||||
@@ -360,6 +364,7 @@ public sealed class AlarmCommandExecutorTests
|
||||
new NullMxAccessServer(),
|
||||
new NoopEventSink(),
|
||||
new MxAccessHandleRegistry(),
|
||||
new MxAccessValueCache(),
|
||||
System.Environment.CurrentManagedThreadId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,53 @@ public sealed class MxAccessBaseEventSinkTests
|
||||
Assert.NotNull(mxEvent.WorkerTimestamp);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an OnDataChange COM callback also writes the value into the
|
||||
/// per-session value cache, so a later <c>ReadBulk</c> on an already-advised
|
||||
/// tag can serve the cached value without re-advising. The cache update must
|
||||
/// fire after the event has cleared the outbound queue — verified here by
|
||||
/// checking the cache only after the queue confirms the enqueue succeeded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void OnDataChange_ComCallback_PopulatesValueCache()
|
||||
{
|
||||
MxAccessEventQueue queue = new();
|
||||
MxAccessValueCache cache = new();
|
||||
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), cache);
|
||||
DateTime timestamp = new(2026, 5, 18, 9, 15, 0, DateTimeKind.Utc);
|
||||
MXSTATUS_PROXY[] statuses = Array.Empty<MXSTATUS_PROXY>();
|
||||
|
||||
sink.OnDataChange(
|
||||
hLMXServerHandle: 7,
|
||||
phItemHandle: 21,
|
||||
pvItemValue: 1234,
|
||||
pwItemQuality: 192,
|
||||
pftItemTimeStamp: timestamp,
|
||||
ref statuses);
|
||||
|
||||
Assert.Equal(1, queue.Count);
|
||||
Assert.True(cache.TryGet(7, 21, out MxAccessValueCache.CachedValue cached));
|
||||
Assert.Equal(1UL, cached.Version);
|
||||
Assert.Equal(1234, cached.Value.Int32Value);
|
||||
Assert.Equal(192, cached.Quality);
|
||||
Assert.Equal(timestamp, cached.SourceTimestamp.ToDateTime());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the sink-bound <c>ValueCache</c> is exposed for sharing with
|
||||
/// the owning <see cref="MxAccessSession"/> so writes and reads see the same
|
||||
/// instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ValueCache_ReturnsTheInstanceBoundAtConstruction()
|
||||
{
|
||||
MxAccessEventQueue queue = new();
|
||||
MxAccessValueCache cache = new();
|
||||
MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), cache);
|
||||
|
||||
Assert.Same(cache, sink.ValueCache);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences.
|
||||
/// </summary>
|
||||
|
||||
@@ -473,6 +473,203 @@ public sealed class MxAccessCommandExecutorTests
|
||||
Assert.Equal(runtime.StaThreadId, fakeComObject.AdviseThreadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that WriteBulk runs MXAccess Write per entry on the STA and returns
|
||||
/// one BulkWriteResult per entry in input order, including a per-entry COM
|
||||
/// failure surfaced as <c>WasSuccessful=false</c> with the underlying HRESULT.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WriteBulk_RunsSequentialWritesAndReturnsPerEntryResults()
|
||||
{
|
||||
const int hresult = unchecked((int)0x80070057);
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 80,
|
||||
writeExceptionByItemHandle: new Dictionary<int, Exception>
|
||||
{
|
||||
[802] = new COMException("Invalid item handle.", hresult),
|
||||
});
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateWriteBulkCommand(
|
||||
"write-bulk",
|
||||
serverHandle: 80,
|
||||
new[]
|
||||
{
|
||||
(itemHandle: 801, value: 11, userId: 5),
|
||||
(itemHandle: 802, value: 22, userId: 5),
|
||||
(itemHandle: 803, value: 33, userId: 5),
|
||||
}));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(MxCommandKind.WriteBulk, reply.Kind);
|
||||
Assert.Equal(3, reply.WriteBulk.Results.Count);
|
||||
|
||||
BulkWriteResult success1 = reply.WriteBulk.Results[0];
|
||||
Assert.True(success1.WasSuccessful);
|
||||
Assert.Equal(801, success1.ItemHandle);
|
||||
Assert.Equal(string.Empty, success1.ErrorMessage);
|
||||
|
||||
BulkWriteResult failure = reply.WriteBulk.Results[1];
|
||||
Assert.False(failure.WasSuccessful);
|
||||
Assert.Equal(802, failure.ItemHandle);
|
||||
Assert.True(failure.HasHresult);
|
||||
Assert.Equal(hresult, failure.Hresult);
|
||||
|
||||
BulkWriteResult success3 = reply.WriteBulk.Results[2];
|
||||
Assert.True(success3.WasSuccessful);
|
||||
Assert.Equal(803, success3.ItemHandle);
|
||||
|
||||
// Each Write hit the fake COM object on the STA thread.
|
||||
Assert.Equal(runtime.StaThreadId, fakeComObject.WriteThreadId);
|
||||
Assert.Contains("Write:80:801", fakeComObject.OperationNames);
|
||||
Assert.Contains("Write:80:802", fakeComObject.OperationNames);
|
||||
Assert.Contains("Write:80:803", fakeComObject.OperationNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Write2Bulk forwards value AND timestamp to each per-entry Write2.</summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_Write2Bulk_ForwardsValueAndTimestampPerEntry()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 81);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateWrite2BulkCommand(
|
||||
"write2-bulk",
|
||||
serverHandle: 81,
|
||||
new[] { (itemHandle: 811, value: 100, timestamp, userId: 7) }));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
BulkWriteResult result = Assert.Single(reply.Write2Bulk.Results);
|
||||
Assert.True(result.WasSuccessful);
|
||||
Assert.Equal(811, result.ItemHandle);
|
||||
Assert.Equal(100, fakeComObject.WriteValue);
|
||||
Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
|
||||
Assert.Equal(7, fakeComObject.WriteUserId);
|
||||
Assert.Contains("Write2:81:811", fakeComObject.OperationNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecuredBulk forwards both user ids per entry.</summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WriteSecuredBulk_ForwardsUserIdsPerEntry()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 82);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredBulkCommand(
|
||||
"write-secured-bulk",
|
||||
serverHandle: 82,
|
||||
new[] { (itemHandle: 821, currentUserId: 11, verifierUserId: 22, value: 555) }));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
BulkWriteResult result = Assert.Single(reply.WriteSecuredBulk.Results);
|
||||
Assert.True(result.WasSuccessful);
|
||||
Assert.Equal(11, fakeComObject.WriteCurrentUserId);
|
||||
Assert.Equal(22, fakeComObject.WriteVerifierUserId);
|
||||
Assert.Equal(555, fakeComObject.WriteValue);
|
||||
Assert.Contains("WriteSecured:82:821", fakeComObject.OperationNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that WriteSecured2Bulk forwards user ids, value, and timestamp per entry.</summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WriteSecured2Bulk_ForwardsUserIdsValueAndTimestampPerEntry()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 83);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2BulkCommand(
|
||||
"write-secured2-bulk",
|
||||
serverHandle: 83,
|
||||
new[] { (itemHandle: 831, currentUserId: 33, verifierUserId: 44, value: 999, timestamp) }));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
BulkWriteResult result = Assert.Single(reply.WriteSecured2Bulk.Results);
|
||||
Assert.True(result.WasSuccessful);
|
||||
Assert.Equal(33, fakeComObject.WriteCurrentUserId);
|
||||
Assert.Equal(44, fakeComObject.WriteVerifierUserId);
|
||||
Assert.Equal(999, fakeComObject.WriteValue);
|
||||
Assert.Equal(timestamp, fakeComObject.WriteTimestamp);
|
||||
Assert.Contains("WriteSecured2:83:831", fakeComObject.OperationNames);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies ReadBulk's snapshot path: with no cached value, the worker takes
|
||||
/// the AddItem + Advise + wait + UnAdvise + RemoveItem lifecycle itself, and
|
||||
/// surfaces a timeout as a per-tag failure when no OnDataChange arrives.
|
||||
/// The fake COM object never fires events so the wait always times out — but
|
||||
/// the lifecycle calls must still happen, in order, on the STA.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ReadBulk_WhenTagNotCached_TakesSnapshotLifecycleAndTimesOut()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(
|
||||
registerHandle: 90,
|
||||
addItemHandle: 900);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
await session.DispatchAsync(CreateRegisterCommand("register-before-read-bulk", "client-a"));
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateReadBulkCommand(
|
||||
"read-bulk-snapshot",
|
||||
serverHandle: 90,
|
||||
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.False(result.WasSuccessful);
|
||||
Assert.False(result.WasCached);
|
||||
Assert.Equal("Galaxy.Tag.Value", result.TagAddress);
|
||||
Assert.Equal(900, result.ItemHandle);
|
||||
Assert.Contains("timed out", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// The snapshot lifecycle must call AddItem → Advise → UnAdvise → RemoveItem
|
||||
// in order on the STA. We don't assert exact ordering of UnAdvise vs.
|
||||
// RemoveItem here because both are best-effort cleanup in a finally
|
||||
// block; the operation list confirms both happened.
|
||||
Assert.Contains("AddItem:90:Galaxy.Tag.Value", fakeComObject.OperationNames);
|
||||
Assert.Contains("Advise:90:900", fakeComObject.OperationNames);
|
||||
Assert.Contains("UnAdvise:90:900", fakeComObject.OperationNames);
|
||||
Assert.Contains("RemoveItem:90:900", fakeComObject.OperationNames);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadBulk with no payload returns an invalid request error.</summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_ReadBulkWithoutPayload_ReturnsInvalidRequest()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 91);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(new StaCommand(
|
||||
"session-1",
|
||||
"missing-read-bulk-payload",
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
}));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that UnsubscribeBulk removes items after UnAdvise failure.</summary>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_UnsubscribeBulk_RemovesItemAfterUnAdviseFailure()
|
||||
@@ -1048,6 +1245,149 @@ public sealed class MxAccessCommandExecutorTests
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateWriteBulkCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
IEnumerable<(int itemHandle, int value, int userId)> entries)
|
||||
{
|
||||
WriteBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
};
|
||||
foreach ((int itemHandle, int value, int userId) in entries)
|
||||
{
|
||||
command.Entries.Add(new WriteBulkEntry
|
||||
{
|
||||
ItemHandle = itemHandle,
|
||||
Value = CreateIntegerValue(value),
|
||||
UserId = userId,
|
||||
});
|
||||
}
|
||||
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
WriteBulk = command,
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateWrite2BulkCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
IEnumerable<(int itemHandle, int value, DateTime timestamp, int userId)> entries)
|
||||
{
|
||||
Write2BulkCommand command = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
};
|
||||
foreach ((int itemHandle, int value, DateTime timestamp, int userId) in entries)
|
||||
{
|
||||
command.Entries.Add(new Write2BulkEntry
|
||||
{
|
||||
ItemHandle = itemHandle,
|
||||
Value = CreateIntegerValue(value),
|
||||
TimestampValue = CreateTimestampValue(timestamp),
|
||||
UserId = userId,
|
||||
});
|
||||
}
|
||||
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = command,
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateWriteSecuredBulkCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value)> entries)
|
||||
{
|
||||
WriteSecuredBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
};
|
||||
foreach ((int itemHandle, int currentUserId, int verifierUserId, int value) in entries)
|
||||
{
|
||||
command.Entries.Add(new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = itemHandle,
|
||||
CurrentUserId = currentUserId,
|
||||
VerifierUserId = verifierUserId,
|
||||
Value = CreateIntegerValue(value),
|
||||
});
|
||||
}
|
||||
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = command,
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateWriteSecured2BulkCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
IEnumerable<(int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp)> entries)
|
||||
{
|
||||
WriteSecured2BulkCommand command = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
};
|
||||
foreach ((int itemHandle, int currentUserId, int verifierUserId, int value, DateTime timestamp) in entries)
|
||||
{
|
||||
command.Entries.Add(new WriteSecured2BulkEntry
|
||||
{
|
||||
ItemHandle = itemHandle,
|
||||
CurrentUserId = currentUserId,
|
||||
VerifierUserId = verifierUserId,
|
||||
Value = CreateIntegerValue(value),
|
||||
TimestampValue = CreateTimestampValue(timestamp),
|
||||
});
|
||||
}
|
||||
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = command,
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateReadBulkCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
IEnumerable<string> tagAddresses,
|
||||
uint timeoutMs)
|
||||
{
|
||||
ReadBulkCommand command = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TimeoutMs = timeoutMs,
|
||||
};
|
||||
command.TagAddresses.Add(tagAddresses);
|
||||
|
||||
return new StaCommand(
|
||||
"session-1",
|
||||
correlationId,
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ReadBulk = command,
|
||||
});
|
||||
}
|
||||
|
||||
private static StaCommand CreateAdviseSupervisoryCommand(
|
||||
string correlationId,
|
||||
int serverHandle,
|
||||
@@ -1087,6 +1427,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
private readonly Exception? adviseException;
|
||||
private readonly Exception? unAdviseException;
|
||||
private readonly Exception? adviseSupervisoryException;
|
||||
private readonly IReadOnlyDictionary<int, Exception> writeExceptionByItemHandle;
|
||||
private readonly List<string> operationNames = new();
|
||||
|
||||
/// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary>
|
||||
@@ -1110,7 +1451,8 @@ public sealed class MxAccessCommandExecutorTests
|
||||
Exception? removeItemException = null,
|
||||
Exception? adviseException = null,
|
||||
Exception? unAdviseException = null,
|
||||
Exception? adviseSupervisoryException = null)
|
||||
Exception? adviseSupervisoryException = null,
|
||||
IReadOnlyDictionary<int, Exception>? writeExceptionByItemHandle = null)
|
||||
{
|
||||
this.registerHandle = registerHandle;
|
||||
this.addItemHandle = addItemHandle;
|
||||
@@ -1122,6 +1464,8 @@ public sealed class MxAccessCommandExecutorTests
|
||||
this.adviseException = adviseException;
|
||||
this.unAdviseException = unAdviseException;
|
||||
this.adviseSupervisoryException = adviseSupervisoryException;
|
||||
this.writeExceptionByItemHandle = writeExceptionByItemHandle
|
||||
?? new Dictionary<int, Exception>();
|
||||
}
|
||||
|
||||
/// <summary>Gets the client name passed to Register, if called.</summary>
|
||||
@@ -1380,6 +1724,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteValue = value;
|
||||
WriteUserId = userId;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Writes a timestamped value to an item and tracks the operation.</summary>
|
||||
@@ -1402,6 +1747,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteTimestamp = timestamp;
|
||||
WriteUserId = userId;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Performs a secured write to an item and tracks the operation.</summary>
|
||||
@@ -1424,6 +1770,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteVerifierUserId = verifierUserId;
|
||||
WriteValue = value;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Performs a secured timestamped write to an item and tracks the operation.</summary>
|
||||
@@ -1449,6 +1796,18 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteValue = value;
|
||||
WriteTimestamp = timestamp;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
}
|
||||
|
||||
private void ThrowIfWriteFailureConfigured(int itemHandle)
|
||||
{
|
||||
// Per-item write-failure injection — used by the bulk-write tests to
|
||||
// exercise the "one bad entry surfaces as was_successful=false but
|
||||
// the loop keeps going" contract on BulkWriteResult.
|
||||
if (writeExceptionByItemHandle.TryGetValue(itemHandle, out Exception? exception))
|
||||
{
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Worker.MxAccess;
|
||||
|
||||
namespace 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
|
||||
{
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGet_WithUnknownHandle_ReturnsFalse()
|
||||
{
|
||||
MxAccessValueCache cache = new();
|
||||
|
||||
Assert.False(cache.TryGet(serverHandle: 7, itemHandle: 21, out _));
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryWaitForUpdate_ReturnsFalseAfterDeadline_WhenNoSetOccurs()
|
||||
{
|
||||
MxAccessValueCache cache = new();
|
||||
int pumpCalls = 0;
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
|
||||
bool result = cache.TryWaitForUpdate(
|
||||
serverHandle: 7,
|
||||
itemHandle: 21,
|
||||
sinceVersion: 0,
|
||||
deadlineUtc: DateTime.UtcNow.AddMilliseconds(80),
|
||||
pumpStep: () => Interlocked.Increment(ref pumpCalls),
|
||||
out MxAccessValueCache.CachedValue value,
|
||||
pollIntervalMs: 5);
|
||||
stopwatch.Stop();
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(default, value.Value);
|
||||
Assert.True(pumpCalls > 1, $"pumpCalls={pumpCalls}: pump step should fire each poll iteration so MXAccess events can dispatch.");
|
||||
Assert.True(stopwatch.ElapsedMilliseconds >= 60, $"elapsed={stopwatch.ElapsedMilliseconds}ms: wait should approximate the deadline.");
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
{
|
||||
private readonly MxAccessEventMapper eventMapper;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private readonly MxAccessValueCache valueCache;
|
||||
private LMXProxyServerClass? server;
|
||||
private string sessionId = string.Empty;
|
||||
|
||||
@@ -21,7 +22,7 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
/// <summary>Initializes a new instance of the MxAccessBaseEventSink class with a provided queue.</summary>
|
||||
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
|
||||
public MxAccessBaseEventSink(MxAccessEventQueue eventQueue)
|
||||
: this(eventQueue, new MxAccessEventMapper())
|
||||
: this(eventQueue, new MxAccessEventMapper(), new MxAccessValueCache())
|
||||
{
|
||||
}
|
||||
|
||||
@@ -31,11 +32,36 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
public MxAccessBaseEventSink(
|
||||
MxAccessEventQueue eventQueue,
|
||||
MxAccessEventMapper eventMapper)
|
||||
: this(eventQueue, eventMapper, new MxAccessValueCache())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the MxAccessBaseEventSink class with
|
||||
/// provided queue, mapper, and a shared value cache. The cache is
|
||||
/// populated from every successful <c>OnDataChange</c> dispatch so the
|
||||
/// worker's ReadBulk executor can satisfy a "current value" request
|
||||
/// from an already-advised tag without touching the subscription.
|
||||
/// </summary>
|
||||
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
|
||||
/// <param name="eventMapper">Converter for MXAccess events to protobuf format.</param>
|
||||
/// <param name="valueCache">Per-session last-value cache shared with the MxAccessSession.</param>
|
||||
public MxAccessBaseEventSink(
|
||||
MxAccessEventQueue eventQueue,
|
||||
MxAccessEventMapper eventMapper,
|
||||
MxAccessValueCache valueCache)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
|
||||
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last-value cache populated by this sink. Exposed so the
|
||||
/// MxAccessSession can share the same instance for ReadBulk lookups.
|
||||
/// </summary>
|
||||
public MxAccessValueCache ValueCache => valueCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Attach(
|
||||
object mxAccessComObject,
|
||||
@@ -81,14 +107,21 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOnDataChange(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
pvItemValue,
|
||||
pwItemQuality,
|
||||
pftItemTimeStamp,
|
||||
statuses));
|
||||
// Build the protobuf event once, enqueue it for the outbound stream, and
|
||||
// also publish it into the per-session value cache so ReadBulk can serve
|
||||
// it as a "current value" without re-advising. The cache update is the
|
||||
// ONLY new side effect — fail-fast on conversion still drops the event
|
||||
// through the same EnqueueEvent path as before.
|
||||
EnqueueEvent(
|
||||
() => eventMapper.CreateOnDataChange(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
pvItemValue,
|
||||
pwItemQuality,
|
||||
pftItemTimeStamp,
|
||||
statuses),
|
||||
mxEvent => valueCache.Set(hLMXServerHandle, phItemHandle, mxEvent));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -152,9 +185,25 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
|
||||
private void EnqueueEvent(Func<Proto.MxEvent> createEvent)
|
||||
{
|
||||
EnqueueEvent(createEvent, postPublish: null);
|
||||
}
|
||||
|
||||
private void EnqueueEvent(Func<Proto.MxEvent> createEvent, Action<Proto.MxEvent>? postPublish)
|
||||
{
|
||||
Proto.MxEvent mxEvent;
|
||||
try
|
||||
{
|
||||
eventQueue.Enqueue(createEvent());
|
||||
mxEvent = createEvent();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
eventQueue.RecordFault(CreateEventConversionFault(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
eventQueue.Enqueue(mxEvent);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -169,6 +218,22 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
// this catch's RecordFault call is then a deliberate near
|
||||
// no-op rather than a second, conflicting fault.
|
||||
eventQueue.RecordFault(CreateEventConversionFault(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only publish to caches/observers after the event has cleared the
|
||||
// queue, so a queue overflow does not leak a "fresher" cached value
|
||||
// than what was actually shipped to the gateway.
|
||||
if (postPublish is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
postPublish(mxEvent);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
eventQueue.RecordFault(CreateEventConversionFault(exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,20 @@ namespace MxGateway.Worker.MxAccess;
|
||||
/// </summary>
|
||||
public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
{
|
||||
/// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary>
|
||||
internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000);
|
||||
|
||||
private readonly MxAccessSession session;
|
||||
private readonly VariantConverter variantConverter;
|
||||
private readonly IAlarmCommandHandler? alarmCommandHandler;
|
||||
private readonly Action pumpStep;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a command executor with an MXAccess session.
|
||||
/// </summary>
|
||||
/// <param name="session">MXAccess session on the STA thread.</param>
|
||||
public MxAccessCommandExecutor(MxAccessSession session)
|
||||
: this(session, new VariantConverter(), alarmCommandHandler: null)
|
||||
: this(session, new VariantConverter(), alarmCommandHandler: null, pumpStep: null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -32,7 +36,7 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
public MxAccessCommandExecutor(
|
||||
MxAccessSession session,
|
||||
VariantConverter variantConverter)
|
||||
: this(session, variantConverter, alarmCommandHandler: null)
|
||||
: this(session, variantConverter, alarmCommandHandler: null, pumpStep: null)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -46,10 +50,29 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
MxAccessSession session,
|
||||
VariantConverter variantConverter,
|
||||
IAlarmCommandHandler? alarmCommandHandler)
|
||||
: this(session, variantConverter, alarmCommandHandler, pumpStep: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a command executor with an MXAccess session, variant
|
||||
/// converter, alarm command handler, and a Windows-message pump action.
|
||||
/// The pump action is invoked from inside <c>ReadBulk</c>'s wait loop so
|
||||
/// MXAccess COM events queued for this STA can be dispatched while the
|
||||
/// executor is still holding the thread. Pass <c>null</c> in tests where
|
||||
/// ReadBulk is exercised against a fake worker that pre-populates the
|
||||
/// value cache — the executor falls back to a no-op pump step.
|
||||
/// </summary>
|
||||
public MxAccessCommandExecutor(
|
||||
MxAccessSession session,
|
||||
VariantConverter variantConverter,
|
||||
IAlarmCommandHandler? alarmCommandHandler,
|
||||
Action? pumpStep)
|
||||
{
|
||||
this.session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
|
||||
this.alarmCommandHandler = alarmCommandHandler;
|
||||
this.pumpStep = pumpStep ?? (static () => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -84,6 +107,11 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
MxCommandKind.UnAdviseItemBulk => ExecuteUnAdviseItemBulk(command),
|
||||
MxCommandKind.SubscribeBulk => ExecuteSubscribeBulk(command),
|
||||
MxCommandKind.UnsubscribeBulk => ExecuteUnsubscribeBulk(command),
|
||||
MxCommandKind.WriteBulk => ExecuteWriteBulk(command),
|
||||
MxCommandKind.Write2Bulk => ExecuteWrite2Bulk(command),
|
||||
MxCommandKind.WriteSecuredBulk => ExecuteWriteSecuredBulk(command),
|
||||
MxCommandKind.WriteSecured2Bulk => ExecuteWriteSecured2Bulk(command),
|
||||
MxCommandKind.ReadBulk => ExecuteReadBulk(command),
|
||||
MxCommandKind.SubscribeAlarms => ExecuteSubscribeAlarms(command),
|
||||
MxCommandKind.UnsubscribeAlarms => ExecuteUnsubscribeAlarms(command),
|
||||
MxCommandKind.AcknowledgeAlarm => ExecuteAcknowledgeAlarm(command),
|
||||
@@ -407,6 +435,149 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
session.UnsubscribeBulk(unsubscribeBulkCommand.ServerHandle, unsubscribeBulkCommand.ItemHandles));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteBulk command payload is required.");
|
||||
}
|
||||
|
||||
WriteBulkCommand writeBulkCommand = command.Command.WriteBulk;
|
||||
foreach (WriteBulkEntry entry in writeBulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteBulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.WriteBulk(
|
||||
writeBulkCommand.ServerHandle,
|
||||
writeBulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWrite2Bulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2Bulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Write2Bulk command payload is required.");
|
||||
}
|
||||
|
||||
Write2BulkCommand write2BulkCommand = command.Command.Write2Bulk;
|
||||
foreach (Write2BulkEntry entry in write2BulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"Write2Bulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
|
||||
if (entry.TimestampValue is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"Write2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.Write2Bulk(
|
||||
write2BulkCommand.ServerHandle,
|
||||
write2BulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteSecuredBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecuredBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecuredBulk command payload is required.");
|
||||
}
|
||||
|
||||
WriteSecuredBulkCommand writeSecuredBulkCommand = command.Command.WriteSecuredBulk;
|
||||
foreach (WriteSecuredBulkEntry entry in writeSecuredBulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteSecuredBulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.WriteSecuredBulk(
|
||||
writeSecuredBulkCommand.ServerHandle,
|
||||
writeSecuredBulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteSecured2Bulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2Bulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecured2Bulk command payload is required.");
|
||||
}
|
||||
|
||||
WriteSecured2BulkCommand writeSecured2BulkCommand = command.Command.WriteSecured2Bulk;
|
||||
foreach (WriteSecured2BulkEntry entry in writeSecured2BulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
|
||||
if (entry.TimestampValue is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.WriteSecured2Bulk(
|
||||
writeSecured2BulkCommand.ServerHandle,
|
||||
writeSecured2BulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteReadBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.ReadBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "ReadBulk command payload is required.");
|
||||
}
|
||||
|
||||
ReadBulkCommand readBulkCommand = command.Command.ReadBulk;
|
||||
TimeSpan timeout = readBulkCommand.TimeoutMs == 0
|
||||
? DefaultReadBulkTimeout
|
||||
: TimeSpan.FromMilliseconds(readBulkCommand.TimeoutMs);
|
||||
|
||||
IReadOnlyList<BulkReadResult> results = session.ReadBulk(
|
||||
readBulkCommand.ServerHandle,
|
||||
readBulkCommand.TagAddresses,
|
||||
timeout,
|
||||
pumpStep);
|
||||
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
BulkReadReply bulkReply = new();
|
||||
bulkReply.Results.Add(results);
|
||||
reply.ReadBulk = bulkReply;
|
||||
return reply;
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteSubscribeAlarms(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.SubscribeAlarms)
|
||||
@@ -653,6 +824,35 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateBulkWriteReply(
|
||||
StaCommand command,
|
||||
IEnumerable<BulkWriteResult> results)
|
||||
{
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
BulkWriteReply bulkReply = new();
|
||||
bulkReply.Results.Add(results);
|
||||
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.WriteBulk:
|
||||
reply.WriteBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.Write2Bulk:
|
||||
reply.Write2Bulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
reply.WriteSecuredBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
reply.WriteSecured2Bulk = bulkReply;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported bulk write command kind {command.Kind}.");
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateInvalidRequestReply(
|
||||
StaCommand command,
|
||||
string message)
|
||||
|
||||
@@ -12,6 +12,7 @@ public sealed class MxAccessSession : IDisposable
|
||||
private readonly IMxAccessServer mxAccessServer;
|
||||
private readonly IMxAccessEventSink eventSink;
|
||||
private readonly MxAccessHandleRegistry handleRegistry;
|
||||
private readonly MxAccessValueCache valueCache;
|
||||
private bool disposed;
|
||||
|
||||
private MxAccessSession(
|
||||
@@ -19,12 +20,14 @@ public sealed class MxAccessSession : IDisposable
|
||||
IMxAccessServer mxAccessServer,
|
||||
IMxAccessEventSink eventSink,
|
||||
MxAccessHandleRegistry handleRegistry,
|
||||
MxAccessValueCache valueCache,
|
||||
int creationThreadId)
|
||||
{
|
||||
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
|
||||
this.mxAccessServer = mxAccessServer ?? throw new ArgumentNullException(nameof(mxAccessServer));
|
||||
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||
this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
|
||||
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
|
||||
CreationThreadId = creationThreadId;
|
||||
}
|
||||
|
||||
@@ -34,6 +37,14 @@ public sealed class MxAccessSession : IDisposable
|
||||
/// <summary>The registry for tracking opened handles.</summary>
|
||||
public MxAccessHandleRegistry HandleRegistry => handleRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session last-value cache populated by the event sink. ReadBulk
|
||||
/// consults this cache before falling back to its own snapshot
|
||||
/// lifecycle so it can serve a "current value" for an already-advised
|
||||
/// tag without touching the existing subscription.
|
||||
/// </summary>
|
||||
public MxAccessValueCache ValueCache => valueCache;
|
||||
|
||||
/// <summary>Creates a WorkerReady message with session metadata.</summary>
|
||||
/// <param name="workerProcessId">Process ID of the worker.</param>
|
||||
public WorkerReady CreateWorkerReady(int workerProcessId)
|
||||
@@ -78,11 +89,21 @@ public sealed class MxAccessSession : IDisposable
|
||||
|
||||
eventSink.Attach(mxAccessComObject, sessionId);
|
||||
|
||||
// Share the event sink's value cache when one is wired (the
|
||||
// production MxAccessBaseEventSink path) so OnDataChange writes and
|
||||
// ReadBulk reads both see the same instance. Fall back to a fresh
|
||||
// cache for test fakes that supply their own sink — ReadBulk simply
|
||||
// never serves cached values in that case.
|
||||
MxAccessValueCache valueCache = eventSink is MxAccessBaseEventSink baseSink
|
||||
? baseSink.ValueCache
|
||||
: new MxAccessValueCache();
|
||||
|
||||
return new MxAccessSession(
|
||||
mxAccessComObject,
|
||||
new MxAccessComServer(mxAccessComObject),
|
||||
eventSink,
|
||||
new MxAccessHandleRegistry(),
|
||||
valueCache,
|
||||
Environment.CurrentManagedThreadId);
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -180,6 +201,10 @@ public sealed class MxAccessSession : IDisposable
|
||||
|
||||
mxAccessServer.RemoveItem(serverHandle, itemHandle);
|
||||
handleRegistry.RemoveItemHandle(serverHandle, itemHandle);
|
||||
// Evict the last-value entry so a future AddItem + Advise on the
|
||||
// same handle id (which MXAccess may reuse) does not serve a stale
|
||||
// OnDataChange snapshot from the previous lifetime.
|
||||
valueCache.Remove(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Advises on item changes with plain subscription.</summary>
|
||||
@@ -513,6 +538,394 @@ public sealed class MxAccessSession : IDisposable
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk write — runs <see cref="Write"/> sequentially for each entry.
|
||||
/// Each entry's <paramref name="convertValue"/> turns the protobuf
|
||||
/// MxValue into a COM-marshalable variant. Per-item failures are
|
||||
/// captured as <see cref="BulkWriteResult"/> entries with
|
||||
/// <c>was_successful = false</c>; the loop never throws.
|
||||
/// </summary>
|
||||
public IReadOnlyList<BulkWriteResult> WriteBulk(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteBulkEntry> entries,
|
||||
Func<MxValue, object?> convertValue)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (entries is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entries));
|
||||
}
|
||||
|
||||
if (convertValue is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(convertValue));
|
||||
}
|
||||
|
||||
List<BulkWriteResult> results = new(entries.Count);
|
||||
foreach (WriteBulkEntry entry in entries)
|
||||
{
|
||||
results.Add(ExecuteBulkWriteEntry(
|
||||
serverHandle,
|
||||
entry.ItemHandle,
|
||||
() => Write(serverHandle, entry.ItemHandle, convertValue(entry.Value), entry.UserId)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Bulk Write2 — sequential MXAccess <see cref="Write2"/> per entry.</summary>
|
||||
public IReadOnlyList<BulkWriteResult> Write2Bulk(
|
||||
int serverHandle,
|
||||
IReadOnlyList<Write2BulkEntry> entries,
|
||||
Func<MxValue, object?> convertValue)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (entries is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entries));
|
||||
}
|
||||
|
||||
if (convertValue is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(convertValue));
|
||||
}
|
||||
|
||||
List<BulkWriteResult> results = new(entries.Count);
|
||||
foreach (Write2BulkEntry entry in entries)
|
||||
{
|
||||
results.Add(ExecuteBulkWriteEntry(
|
||||
serverHandle,
|
||||
entry.ItemHandle,
|
||||
() => Write2(
|
||||
serverHandle,
|
||||
entry.ItemHandle,
|
||||
convertValue(entry.Value),
|
||||
convertValue(entry.TimestampValue),
|
||||
entry.UserId)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Bulk WriteSecured — sequential MXAccess <see cref="WriteSecured"/> per entry.</summary>
|
||||
public IReadOnlyList<BulkWriteResult> WriteSecuredBulk(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecuredBulkEntry> entries,
|
||||
Func<MxValue, object?> convertValue)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (entries is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entries));
|
||||
}
|
||||
|
||||
if (convertValue is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(convertValue));
|
||||
}
|
||||
|
||||
List<BulkWriteResult> results = new(entries.Count);
|
||||
foreach (WriteSecuredBulkEntry entry in entries)
|
||||
{
|
||||
results.Add(ExecuteBulkWriteEntry(
|
||||
serverHandle,
|
||||
entry.ItemHandle,
|
||||
() => WriteSecured(
|
||||
serverHandle,
|
||||
entry.ItemHandle,
|
||||
entry.CurrentUserId,
|
||||
entry.VerifierUserId,
|
||||
convertValue(entry.Value))));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Bulk WriteSecured2 — sequential MXAccess <see cref="WriteSecured2"/> per entry.</summary>
|
||||
public IReadOnlyList<BulkWriteResult> WriteSecured2Bulk(
|
||||
int serverHandle,
|
||||
IReadOnlyList<WriteSecured2BulkEntry> entries,
|
||||
Func<MxValue, object?> convertValue)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (entries is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entries));
|
||||
}
|
||||
|
||||
if (convertValue is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(convertValue));
|
||||
}
|
||||
|
||||
List<BulkWriteResult> results = new(entries.Count);
|
||||
foreach (WriteSecured2BulkEntry entry in entries)
|
||||
{
|
||||
results.Add(ExecuteBulkWriteEntry(
|
||||
serverHandle,
|
||||
entry.ItemHandle,
|
||||
() => WriteSecured2(
|
||||
serverHandle,
|
||||
entry.ItemHandle,
|
||||
entry.CurrentUserId,
|
||||
entry.VerifierUserId,
|
||||
convertValue(entry.Value),
|
||||
convertValue(entry.TimestampValue))));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bulk read snapshot. For each requested tag, returns the most recent
|
||||
/// OnDataChange value if the tag is already advised AND a cached value
|
||||
/// exists (no subscription side effects); otherwise takes the AddItem
|
||||
/// + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle itself.
|
||||
/// <paramref name="timeout"/> bounds the wait per-tag in the snapshot
|
||||
/// case; <paramref name="pumpStep"/> is invoked on every poll
|
||||
/// iteration so the worker's STA can dispatch the incoming MXAccess
|
||||
/// message that carries the value.
|
||||
/// </summary>
|
||||
public IReadOnlyList<BulkReadResult> ReadBulk(
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
TimeSpan timeout,
|
||||
Action pumpStep)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
if (tagAddresses is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(tagAddresses));
|
||||
}
|
||||
|
||||
if (pumpStep is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(pumpStep));
|
||||
}
|
||||
|
||||
List<BulkReadResult> results = new(tagAddresses.Count);
|
||||
foreach (string? tagAddress in tagAddresses)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||
{
|
||||
results.Add(FailedRead(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, wasCached: false, "Tag address is required."));
|
||||
continue;
|
||||
}
|
||||
|
||||
results.Add(ReadOneTag(serverHandle, tagAddress, timeout, pumpStep));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private BulkReadResult ReadOneTag(
|
||||
int serverHandle,
|
||||
string tagAddress,
|
||||
TimeSpan timeout,
|
||||
Action pumpStep)
|
||||
{
|
||||
// 1. Cached-and-advised fast path: scan the registry for a live item
|
||||
// handle matching this tag and check whether the value cache has a
|
||||
// payload for it. If so, return the cached value without touching
|
||||
// the existing subscription — the caller didn't create it, so
|
||||
// ReadBulk must not tear it down.
|
||||
if (TryGetCachedReadFor(serverHandle, tagAddress, out int cachedItemHandle, out MxAccessValueCache.CachedValue cachedValue))
|
||||
{
|
||||
return SucceededRead(
|
||||
serverHandle,
|
||||
tagAddress,
|
||||
cachedItemHandle,
|
||||
wasCached: true,
|
||||
cachedValue);
|
||||
}
|
||||
|
||||
// 2. Snapshot lifecycle. Reserve our own item handle, advise, pump
|
||||
// until we see a fresh OnDataChange (or the deadline elapses),
|
||||
// then tear it down.
|
||||
int itemHandle = 0;
|
||||
bool advised = false;
|
||||
try
|
||||
{
|
||||
itemHandle = AddItem(serverHandle, tagAddress);
|
||||
ulong baseline = valueCache.CurrentVersion(serverHandle, itemHandle);
|
||||
Advise(serverHandle, itemHandle);
|
||||
advised = true;
|
||||
|
||||
DateTime deadline = DateTime.UtcNow + timeout;
|
||||
bool gotValue = valueCache.TryWaitForUpdate(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
baseline,
|
||||
deadline,
|
||||
pumpStep,
|
||||
out MxAccessValueCache.CachedValue snapshot);
|
||||
|
||||
return gotValue
|
||||
? SucceededRead(serverHandle, tagAddress, itemHandle, wasCached: false, snapshot)
|
||||
: FailedRead(
|
||||
serverHandle,
|
||||
tagAddress,
|
||||
itemHandle,
|
||||
wasCached: false,
|
||||
$"ReadBulk timed out after {timeout.TotalMilliseconds:F0} ms waiting for first OnDataChange.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return FailedRead(serverHandle, tagAddress, itemHandle, wasCached: false, exception.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Snapshot teardown — best-effort. Errors here are noted on the
|
||||
// diagnostic message of the original result (above) by appending
|
||||
// a cleanup suffix; we never re-throw from finally.
|
||||
if (advised)
|
||||
{
|
||||
try { UnAdvise(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
|
||||
}
|
||||
|
||||
if (itemHandle != 0)
|
||||
{
|
||||
try { RemoveItem(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedReadFor(
|
||||
int serverHandle,
|
||||
string tagAddress,
|
||||
out int itemHandle,
|
||||
out MxAccessValueCache.CachedValue cachedValue)
|
||||
{
|
||||
// Linear scan — bulk-read sizes are small in practice and the registry
|
||||
// is keyed by handle, not by tag. If profiling ever shows this hot, a
|
||||
// reverse tag→handle map can be added on the registry side.
|
||||
foreach (RegisteredItemHandle registered in handleRegistry.ItemHandles)
|
||||
{
|
||||
if (registered.ServerHandle != serverHandle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(registered.ItemDefinition, tagAddress, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Plain)
|
||||
&& !handleRegistry.ContainsAdviceHandle(serverHandle, registered.ItemHandle, MxAccessAdviceKind.Supervisory))
|
||||
{
|
||||
// Tag is added but not advised — no fresh OnDataChange will
|
||||
// arrive without us advising. Fall through to the snapshot
|
||||
// path which advises explicitly.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueCache.TryGet(serverHandle, registered.ItemHandle, out cachedValue))
|
||||
{
|
||||
itemHandle = registered.ItemHandle;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
itemHandle = 0;
|
||||
cachedValue = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private BulkWriteResult ExecuteBulkWriteEntry(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
Action invokeWrite)
|
||||
{
|
||||
try
|
||||
{
|
||||
invokeWrite();
|
||||
return new BulkWriteResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = true,
|
||||
ErrorMessage = string.Empty,
|
||||
};
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException comException)
|
||||
{
|
||||
BulkWriteResult result = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = comException.Message,
|
||||
};
|
||||
result.Hresult = comException.HResult;
|
||||
return result;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return new BulkWriteResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = exception.Message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static BulkReadResult SucceededRead(
|
||||
int serverHandle,
|
||||
string tagAddress,
|
||||
int itemHandle,
|
||||
bool wasCached,
|
||||
MxAccessValueCache.CachedValue snapshot)
|
||||
{
|
||||
BulkReadResult result = new()
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TagAddress = tagAddress,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = true,
|
||||
WasCached = wasCached,
|
||||
Quality = snapshot.Quality,
|
||||
ErrorMessage = string.Empty,
|
||||
};
|
||||
|
||||
if (snapshot.Value is not null)
|
||||
{
|
||||
result.Value = snapshot.Value;
|
||||
}
|
||||
|
||||
if (snapshot.SourceTimestamp is not null)
|
||||
{
|
||||
result.SourceTimestamp = snapshot.SourceTimestamp;
|
||||
}
|
||||
|
||||
if (snapshot.Statuses is not null)
|
||||
{
|
||||
result.Statuses.Add(snapshot.Statuses);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static BulkReadResult FailedRead(
|
||||
int serverHandle,
|
||||
string tagAddress,
|
||||
int itemHandle,
|
||||
bool wasCached,
|
||||
string errorMessage)
|
||||
{
|
||||
return new BulkReadResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TagAddress = tagAddress,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = false,
|
||||
WasCached = wasCached,
|
||||
ErrorMessage = errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Gracefully shuts down the session, cleaning up all handles.</summary>
|
||||
public MxAccessShutdownResult ShutdownGracefully()
|
||||
{
|
||||
|
||||
@@ -196,7 +196,13 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
new MxAccessCommandExecutor(
|
||||
session,
|
||||
new VariantConverter(),
|
||||
alarmCommandHandler));
|
||||
alarmCommandHandler,
|
||||
// ReadBulk needs to pump Windows messages while it waits
|
||||
// for the first OnDataChange callback so the inbound COM
|
||||
// event can dispatch on this same STA thread. The pump
|
||||
// step closes over staRuntime so it always pumps the
|
||||
// pump tied to the apartment that owns this session.
|
||||
pumpStep: () => staRuntime.PumpPendingMessages()));
|
||||
|
||||
return session.CreateWorkerReady(workerProcessId);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Google.Protobuf.Collections;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session cache of the most recent <c>OnDataChange</c> payload for
|
||||
/// each (server handle, item handle) pair. Written by the MXAccess event
|
||||
/// sink as new OnDataChange callbacks arrive; read by the ReadBulk command
|
||||
/// executor so it can satisfy a "current value" request from a tag that is
|
||||
/// already advised without modifying the existing subscription.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both writers and readers run on the worker's STA thread (COM dispatches
|
||||
/// events on the apartment thread; commands also execute on the STA), so
|
||||
/// no internal locking is required. The class is still nominally
|
||||
/// thread-safe via a single sync root in case tests drive it from a
|
||||
/// non-STA thread.
|
||||
/// </remarks>
|
||||
public sealed class MxAccessValueCache
|
||||
{
|
||||
private readonly Dictionary<long, CachedValue> entries = new();
|
||||
private readonly object syncRoot = new();
|
||||
|
||||
/// <summary>Records a fresh OnDataChange payload for the given handle pair.</summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle.</param>
|
||||
/// <param name="mxEvent">The protobuf MxEvent created by the event mapper.</param>
|
||||
public void Set(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxEvent mxEvent)
|
||||
{
|
||||
if (mxEvent is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(mxEvent));
|
||||
}
|
||||
|
||||
long key = CreateItemKey(serverHandle, itemHandle);
|
||||
lock (syncRoot)
|
||||
{
|
||||
ulong nextVersion = entries.TryGetValue(key, out CachedValue existing)
|
||||
? existing.Version + 1
|
||||
: 1UL;
|
||||
|
||||
entries[key] = new CachedValue(
|
||||
nextVersion,
|
||||
mxEvent.Value,
|
||||
mxEvent.Quality,
|
||||
mxEvent.SourceTimestamp,
|
||||
mxEvent.Statuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tries to read the most recent cached value for the handle pair.</summary>
|
||||
public bool TryGet(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
out CachedValue value)
|
||||
{
|
||||
long key = CreateItemKey(serverHandle, itemHandle);
|
||||
lock (syncRoot)
|
||||
{
|
||||
return entries.TryGetValue(key, out value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the cache slot for a handle pair. The session calls this
|
||||
/// when an item is unregistered so stale values are not served to a
|
||||
/// subsequent ReadBulk after a tag is removed and re-added.
|
||||
/// </summary>
|
||||
public void Remove(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
long key = CreateItemKey(serverHandle, itemHandle);
|
||||
lock (syncRoot)
|
||||
{
|
||||
entries.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the cache entry's version exceeds <paramref name="sinceVersion"/>
|
||||
/// or the deadline elapses, calling <paramref name="pumpStep"/> on every poll
|
||||
/// iteration so the worker's STA can dispatch the inbound MXAccess message.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle.</param>
|
||||
/// <param name="sinceVersion">Version snapshot captured before the wait.</param>
|
||||
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
|
||||
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
|
||||
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
|
||||
public bool TryWaitForUpdate(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
ulong sinceVersion,
|
||||
DateTime deadlineUtc,
|
||||
Action pumpStep,
|
||||
out CachedValue value,
|
||||
int pollIntervalMs = 5)
|
||||
{
|
||||
if (pumpStep is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(pumpStep));
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
pumpStep();
|
||||
|
||||
if (TryGet(serverHandle, itemHandle, out value) && value.Version > sinceVersion)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow >= deadlineUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current version for a handle pair, or 0 if no entry exists.</summary>
|
||||
public ulong CurrentVersion(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return TryGet(serverHandle, itemHandle, out CachedValue existing)
|
||||
? existing.Version
|
||||
: 0UL;
|
||||
}
|
||||
|
||||
private static long CreateItemKey(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return ((long)serverHandle << 32) | (uint)itemHandle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the most recent OnDataChange payload for a handle pair.
|
||||
/// <see cref="Version"/> increments by one on every <see cref="Set"/>
|
||||
/// call so the bulk read executor can detect "a new value arrived
|
||||
/// since I started waiting".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain readonly struct (not a record) so this compiles under the
|
||||
/// worker's net48 target, which lacks <c>IsExternalInit</c>.
|
||||
/// </remarks>
|
||||
public readonly struct CachedValue
|
||||
{
|
||||
/// <summary>Initializes a new cached value snapshot.</summary>
|
||||
public CachedValue(
|
||||
ulong version,
|
||||
MxValue value,
|
||||
int quality,
|
||||
Timestamp sourceTimestamp,
|
||||
RepeatedField<MxStatusProxy> statuses)
|
||||
{
|
||||
Version = version;
|
||||
Value = value;
|
||||
Quality = quality;
|
||||
SourceTimestamp = sourceTimestamp;
|
||||
Statuses = statuses;
|
||||
}
|
||||
|
||||
/// <summary>Monotonic per-handle version counter.</summary>
|
||||
public ulong Version { get; }
|
||||
|
||||
/// <summary>The cached MxValue payload.</summary>
|
||||
public MxValue Value { get; }
|
||||
|
||||
/// <summary>Quality code from the OnDataChange event.</summary>
|
||||
public int Quality { get; }
|
||||
|
||||
/// <summary>Source timestamp from the OnDataChange event.</summary>
|
||||
public Timestamp SourceTimestamp { get; }
|
||||
|
||||
/// <summary>MxStatusProxy entries from the OnDataChange event.</summary>
|
||||
public RepeatedField<MxStatusProxy> Statuses { get; }
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,15 @@ public sealed class StaRuntime : IDisposable
|
||||
/// </summary>
|
||||
public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet;
|
||||
|
||||
/// <summary>
|
||||
/// Pumps any pending Windows messages on the calling thread. Intended
|
||||
/// for commands that synchronously hold the STA (e.g. ReadBulk) and
|
||||
/// must allow inbound MXAccess COM events to dispatch while they
|
||||
/// wait. Callers must already be on the STA; the method is otherwise
|
||||
/// safe (PeekMessage simply finds no messages).
|
||||
/// </summary>
|
||||
public int PumpPendingMessages() => messagePump.PumpPendingMessages();
|
||||
|
||||
/// <summary>
|
||||
/// Starts the STA thread.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user