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:
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