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
+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(