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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user