Files
mxaccessgw/src/MxGateway.Worker/MxAccess/MxAccessSession.cs
T
Joseph Doherty 5e375f6d3d 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>
2026-05-20 03:42:38 -04:00

1127 lines
38 KiB
C#

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
namespace MxGateway.Worker.MxAccess;
public sealed class MxAccessSession : IDisposable
{
private readonly object mxAccessComObject;
private readonly IMxAccessServer mxAccessServer;
private readonly IMxAccessEventSink eventSink;
private readonly MxAccessHandleRegistry handleRegistry;
private readonly MxAccessValueCache valueCache;
private bool disposed;
private MxAccessSession(
object mxAccessComObject,
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;
}
/// <summary>The thread ID where this session was created.</summary>
public int CreationThreadId { get; }
/// <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)
{
return new WorkerReady
{
WorkerProcessId = workerProcessId,
MxaccessProgid = MxAccessInteropInfo.ProgId,
MxaccessClsid = MxAccessInteropInfo.Clsid,
ReadyTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
};
}
/// <summary>Creates and initializes an MXAccess COM session.</summary>
/// <param name="factory">Factory to create the MXAccess COM object.</param>
/// <param name="eventSink">Event sink to attach to the COM object.</param>
/// <param name="sessionId">Identifier of the session.</param>
public static MxAccessSession Create(
IMxAccessComObjectFactory factory,
IMxAccessEventSink eventSink,
string sessionId)
{
if (factory is null)
{
throw new ArgumentNullException(nameof(factory));
}
if (eventSink is null)
{
throw new ArgumentNullException(nameof(eventSink));
}
object? mxAccessComObject = null;
try
{
mxAccessComObject = factory.Create();
if (mxAccessComObject is null)
{
throw new InvalidOperationException("MXAccess COM factory returned null.");
}
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)
{
try
{
eventSink.Detach();
}
catch
{
// Preserve the creation failure while still releasing the COM object below.
}
if (mxAccessComObject is not null && Marshal.IsComObject(mxAccessComObject))
{
Marshal.FinalReleaseComObject(mxAccessComObject);
}
throw MxAccessCreationException.From(exception);
}
}
/// <summary>Registers a client with MXAccess and returns the server handle.</summary>
/// <param name="clientName">Name of the client to register.</param>
public int Register(string clientName)
{
ThrowIfDisposed();
int serverHandle = mxAccessServer.Register(clientName);
handleRegistry.RegisterServerHandle(serverHandle, clientName);
return serverHandle;
}
/// <summary>Unregisters a client from MXAccess.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
public void Unregister(int serverHandle)
{
ThrowIfDisposed();
mxAccessServer.Unregister(serverHandle);
handleRegistry.UnregisterServerHandle(serverHandle);
}
/// <summary>Adds an item to an MXAccess server and returns the item handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Definition or address of the item to add.</param>
public int AddItem(
int serverHandle,
string itemDefinition)
{
ThrowIfDisposed();
int itemHandle = mxAccessServer.AddItem(serverHandle, itemDefinition);
handleRegistry.RegisterItemHandle(
serverHandle,
itemHandle,
itemDefinition,
string.Empty,
hasItemContext: false);
return itemHandle;
}
/// <summary>Adds an item with context to an MXAccess server and returns the item handle.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemDefinition">Definition or address of the item to add.</param>
/// <param name="itemContext">Context string for the item.</param>
public int AddItem2(
int serverHandle,
string itemDefinition,
string itemContext)
{
ThrowIfDisposed();
int itemHandle = mxAccessServer.AddItem2(serverHandle, itemDefinition, itemContext);
handleRegistry.RegisterItemHandle(
serverHandle,
itemHandle,
itemDefinition,
itemContext,
hasItemContext: true);
return itemHandle;
}
/// <summary>Removes an item from an MXAccess server.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void RemoveItem(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
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>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void Advise(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
mxAccessServer.Advise(serverHandle, itemHandle);
handleRegistry.RegisterAdviceHandle(
serverHandle,
itemHandle,
MxAccessAdviceKind.Plain);
}
/// <summary>Removes plain advice subscription from an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void UnAdvise(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
mxAccessServer.UnAdvise(serverHandle, itemHandle);
handleRegistry.RemoveAdviceHandles(serverHandle, itemHandle);
}
/// <summary>Advises on item changes with supervisory subscription.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
public void AdviseSupervisory(
int serverHandle,
int itemHandle)
{
ThrowIfDisposed();
mxAccessServer.AdviseSupervisory(serverHandle, itemHandle);
handleRegistry.RegisterAdviceHandle(
serverHandle,
itemHandle,
MxAccessAdviceKind.Supervisory);
}
/// <summary>Writes a value to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
public void Write(
int serverHandle,
int itemHandle,
object? value,
int userId)
{
ThrowIfDisposed();
mxAccessServer.Write(serverHandle, itemHandle, value, userId);
}
/// <summary>Writes a value with an explicit source timestamp to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
public void Write2(
int serverHandle,
int itemHandle,
object? value,
object? timestamp,
int userId)
{
ThrowIfDisposed();
mxAccessServer.Write2(serverHandle, itemHandle, value, timestamp, userId);
}
/// <summary>Performs a secured/verified write to an item.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write.</param>
public void WriteSecured(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value)
{
ThrowIfDisposed();
mxAccessServer.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
/// <summary>Performs a secured/verified write with an explicit source timestamp.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandle">Handle returned by the worker.</param>
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
/// <param name="value">COM-marshalable value to write.</param>
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
public void WriteSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
object? value,
object? timestamp)
{
ThrowIfDisposed();
mxAccessServer.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp);
}
/// <summary>Adds multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="tagAddresses">Enumerable of item definitions to add.</param>
public IReadOnlyList<SubscribeResult> AddItemBulk(
int serverHandle,
IEnumerable<string> tagAddresses)
{
ThrowIfDisposed();
if (tagAddresses is null)
{
throw new ArgumentNullException(nameof(tagAddresses));
}
List<SubscribeResult> results = new();
foreach (string? tagAddress in tagAddresses)
{
if (string.IsNullOrWhiteSpace(tagAddress))
{
results.Add(Failed(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, "Tag address is required."));
continue;
}
try
{
int itemHandle = AddItem(serverHandle, tagAddress);
results.Add(Succeeded(serverHandle, tagAddress, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, tagAddress, itemHandle: 0, exception.Message));
}
}
return results;
}
/// <summary>Advises on multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to advise on.</param>
public IReadOnlyList<SubscribeResult> AdviseItemBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
try
{
Advise(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
}
}
return results;
}
/// <summary>Removes multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to remove.</param>
public IReadOnlyList<SubscribeResult> RemoveItemBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
try
{
RemoveItem(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
}
}
return results;
}
/// <summary>Removes advice subscriptions from multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to unadvise.</param>
public IReadOnlyList<SubscribeResult> UnAdviseItemBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
try
{
UnAdvise(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, string.Empty, itemHandle));
}
catch (Exception exception)
{
results.Add(Failed(serverHandle, string.Empty, itemHandle, exception.Message));
}
}
return results;
}
/// <summary>Adds multiple items and subscribes to them in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="tagAddresses">Enumerable of item definitions to add and subscribe to.</param>
public IReadOnlyList<SubscribeResult> SubscribeBulk(
int serverHandle,
IEnumerable<string> tagAddresses)
{
ThrowIfDisposed();
if (tagAddresses is null)
{
throw new ArgumentNullException(nameof(tagAddresses));
}
List<SubscribeResult> results = new();
foreach (string? tagAddress in tagAddresses)
{
if (string.IsNullOrWhiteSpace(tagAddress))
{
results.Add(Failed(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, "Tag address is required."));
continue;
}
int itemHandle = 0;
try
{
itemHandle = AddItem(serverHandle, tagAddress);
Advise(serverHandle, itemHandle);
results.Add(Succeeded(serverHandle, tagAddress, itemHandle));
}
catch (Exception exception)
{
string errorMessage = exception.Message;
if (itemHandle != 0)
{
errorMessage = AppendRemoveItemCleanup(serverHandle, itemHandle, errorMessage);
}
results.Add(Failed(serverHandle, tagAddress, itemHandle, errorMessage));
}
}
return results;
}
/// <summary>Unsubscribes from multiple items in bulk, returning success/failure results.</summary>
/// <param name="serverHandle">Handle returned by the worker.</param>
/// <param name="itemHandles">Enumerable of item handles to unsubscribe from.</param>
public IReadOnlyList<SubscribeResult> UnsubscribeBulk(
int serverHandle,
IEnumerable<int> itemHandles)
{
ThrowIfDisposed();
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
List<SubscribeResult> results = new();
foreach (int itemHandle in itemHandles)
{
List<string> errors = new();
try
{
UnAdvise(serverHandle, itemHandle);
}
catch (Exception exception)
{
errors.Add($"UnAdvise failed: {exception.Message}");
}
try
{
RemoveItem(serverHandle, itemHandle);
}
catch (Exception exception)
{
errors.Add($"RemoveItem failed: {exception.Message}");
}
results.Add(errors.Count == 0
? Succeeded(serverHandle, string.Empty, itemHandle)
: Failed(serverHandle, string.Empty, itemHandle, string.Join("; ", errors)));
}
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()
{
if (disposed)
{
return new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
}
List<MxAccessShutdownFailure> failures = new();
CleanupAdviceHandles(failures);
CleanupItemHandles(failures);
CleanupServerHandles(failures);
DisposeCore(failures);
return new MxAccessShutdownResult(failures);
}
/// <summary>Releases the MXAccess COM object and resources.</summary>
public void Dispose()
{
if (disposed)
{
return;
}
DisposeCore(failures: null);
}
private void CleanupAdviceHandles(ICollection<MxAccessShutdownFailure> failures)
{
HashSet<long> cleanedPairs = new();
foreach (RegisteredAdviceHandle adviceHandle in handleRegistry.AdviceHandles)
{
long key = CreateItemKey(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
if (!cleanedPairs.Add(key))
{
continue;
}
try
{
mxAccessServer.UnAdvise(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
handleRegistry.RemoveAdviceHandles(adviceHandle.ServerHandle, adviceHandle.ItemHandle);
}
catch (Exception exception)
{
failures.Add(new MxAccessShutdownFailure(
nameof(UnAdvise),
adviceHandle.ServerHandle,
adviceHandle.ItemHandle,
exception));
}
}
}
private void CleanupItemHandles(ICollection<MxAccessShutdownFailure> failures)
{
foreach (RegisteredItemHandle itemHandle in handleRegistry.ItemHandles)
{
try
{
mxAccessServer.RemoveItem(itemHandle.ServerHandle, itemHandle.ItemHandle);
handleRegistry.RemoveItemHandle(itemHandle.ServerHandle, itemHandle.ItemHandle);
}
catch (Exception exception)
{
failures.Add(new MxAccessShutdownFailure(
nameof(RemoveItem),
itemHandle.ServerHandle,
itemHandle.ItemHandle,
exception));
}
}
}
private void CleanupServerHandles(ICollection<MxAccessShutdownFailure> failures)
{
foreach (RegisteredServerHandle serverHandle in handleRegistry.ServerHandles)
{
try
{
mxAccessServer.Unregister(serverHandle.ServerHandle);
handleRegistry.UnregisterServerHandle(serverHandle.ServerHandle);
}
catch (Exception exception)
{
failures.Add(new MxAccessShutdownFailure(
nameof(Unregister),
serverHandle.ServerHandle,
itemHandle: null,
exception));
}
}
}
private static long CreateItemKey(
int serverHandle,
int itemHandle)
{
return ((long)serverHandle << 32) | (uint)itemHandle;
}
private string AppendRemoveItemCleanup(
int serverHandle,
int itemHandle,
string errorMessage)
{
try
{
RemoveItem(serverHandle, itemHandle);
return $"{errorMessage}; cleanup RemoveItem succeeded.";
}
catch (Exception cleanupException)
{
return $"{errorMessage}; cleanup RemoveItem failed: {cleanupException.Message}";
}
}
private static SubscribeResult Succeeded(
int serverHandle,
string tagAddress,
int itemHandle)
{
return new SubscribeResult
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
ItemHandle = itemHandle,
WasSuccessful = true,
ErrorMessage = string.Empty,
};
}
private static SubscribeResult Failed(
int serverHandle,
string tagAddress,
int itemHandle,
string errorMessage)
{
return new SubscribeResult
{
ServerHandle = serverHandle,
TagAddress = tagAddress,
ItemHandle = itemHandle,
WasSuccessful = false,
ErrorMessage = errorMessage,
};
}
private void DisposeCore(ICollection<MxAccessShutdownFailure>? failures)
{
Exception? detachException = null;
try
{
eventSink.Detach();
}
catch (Exception exception)
{
detachException = exception;
failures?.Add(new MxAccessShutdownFailure(
"DetachEvents",
serverHandle: null,
itemHandle: null,
exception));
}
try
{
if (Marshal.IsComObject(mxAccessComObject))
{
Marshal.FinalReleaseComObject(mxAccessComObject);
}
}
catch (Exception exception) when (failures is not null)
{
failures.Add(new MxAccessShutdownFailure(
"ReleaseComObject",
serverHandle: null,
itemHandle: null,
exception));
}
disposed = true;
if (detachException is not null && failures is null)
{
throw detachException;
}
}
private void ThrowIfDisposed()
{
if (disposed)
{
throw new ObjectDisposedException(nameof(MxAccessSession));
}
}
}