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:
Joseph Doherty
2026-05-20 03:42:38 -04:00
parent 758aca2355
commit 5e375f6d3d
41 changed files with 25624 additions and 1339 deletions
@@ -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>
+42
View File
@@ -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
View File
@@ -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{
+136
View File
@@ -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)
+36
View File
@@ -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
+12
View File
@@ -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
@@ -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}.
*
@@ -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);
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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
+132
View File
@@ -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(
+10
View File
@@ -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
View File
@@ -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),
+135 -4
View File
@@ -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,