9.8 KiB
WriteSecured Completion Correlation — Design
Date: 2026-08-09 Requested by: OtOpcUa (archreview finding 06/S-1, cross-repo residual — gateway half) Status: approved for implementation (peer contract confirmed over cross-session message)
Problem
The unary Invoke reply for a WriteSecured / WriteSecured2 command proves worker-side
command acceptance only. MXAccess writes are fire-and-forget at the toolkit level: the
real per-item outcome arrives later in the OnWriteComplete COM callback. Today the worker
returns CreateOkReply immediately after the COM call, so MxCommandReply.statuses is
always empty and consumers (OtOpcUa's GatewayGalaxyDataWriter.TranslateReply) must treat
every write as provisionally good (galaxy.writes.unconfirmed meter).
Consumer contract (already shipped OtOpcUa-side): statuses.Count > 0 → map statuses[0]
through the MX status map to a real OPC UA StatusCode; empty → provisional Good +
unconfirmed meter. The statuses field already exists on MxCommandReply (field 7); no
proto shape change is needed.
Approaches Considered
- Executor pump-wait + versioned completion cache (chosen). After the
WriteSecuredCOM call, the executor holds the STA thread but explicitly pumps Windows messages each poll iteration until the matching completion is recorded or a bounded deadline passes. This is exactly the shippedReadBulkpattern (MxAccessValueCache.TryWaitForUpdate+pumpStep→StaRuntime.PumpPendingMessages()), so it adds no new threading model. - Parked asynchronous reply. Executor returns a "pending" marker; the dispatcher parks
the correlation and the pipe reply is written later from the event dispatch. Rejected:
changes the
IStaCommandExecutor/dispatcher/pipe contracts for no real gain — commands serialize per session anyway, so freeing the STA during the wait buys nothing. - Gateway-side correlation. Gateway watches the session event stream for the
OnWriteCompleteafter the worker reply. Rejected: races the event drain cadence, couples the gateway to event semantics, still holds the unary RPC, and spreads the feature across two processes.
Design
All changes are worker-side (ZB.MOM.WW.MxGateway.Worker, net48 x86). The gateway's
Invoke already forwards the worker MxCommandReply (statuses included) verbatim.
New: MxAccessWriteCompletionCache
Mirror of MxAccessValueCache, keyed by (serverHandle, itemHandle) (packed long), one
entry per key holding the most recent completion's RepeatedField<MxStatusProxy> (cloned)
plus a monotonically increasing per-key Version. API:
Record(int serverHandle, int itemHandle, RepeatedField<MxStatusProxy> statuses)ulong CurrentVersion(int serverHandle, int itemHandle)— 0 when absentbool TryWaitForCompletion(int serverHandle, int itemHandle, ulong sinceVersion, DateTime deadlineUtc, Action pumpStep, out RepeatedField<MxStatusProxy> statuses, int pollIntervalMs = 5)— pump/poll loop identical in shape toMxAccessValueCache.TryWaitForUpdate.
Same locking posture as the value cache: everything runs on the STA thread; a sync root keeps it nominally thread-safe for tests.
Sink: record completions
MxAccessBaseEventSink owns a MxAccessWriteCompletionCache (new optional ctor param,
exposed as a property) and its OnWriteComplete handler records into it via the existing
EnqueueEvent post-publish hook (same pattern as the value cache on OnDataChange):
the streamed MxEvent is built exactly once by the mapper, enqueued unchanged for the
event stream, and its Statuses are then recorded into the cache. The event stream is
not altered — nothing is swallowed or synthesized.
A new seam interface IWriteCompletionCacheProvider { MxAccessWriteCompletionCache WriteCompletionCache { get; } } is implemented by MxAccessBaseEventSink and by test
sinks. MxAccessSession.Create pulls the cache from the sink through that interface
(fallback: fresh instance), mirroring the existing ValueCache sharing, and exposes it
on the session.
Executor: bounded pump-wait
In ExecuteWriteSecured / ExecuteWriteSecured2:
- Capture
baseline = cache.CurrentVersion(serverHandle, itemHandle)before the COM call — this closes the fast-completion ordering edge: a callback that dispatches during or immediately after the COM call bumps the version past the baseline and still correlates. - Call
session.WriteSecured(...)as today. cache.TryWaitForCompletion(..., baseline, deadline, pumpStep, out statuses); on success,reply.Statuses.Add(statuses); on timeout, return the reply exactly as today (protocol OK, empty statuses) — the consumer's honest-unconfirmed path. No invented failure rows.
The reply's ProtocolStatus/Hresult stay untouched by the completion outcome: the
command was accepted; the MX outcome (success or failure) is carried only in
statuses[0]. That preserves MXAccess parity (the native API returns void; the outcome
exists only in the callback) while enriching the reply with information that was already
on the wire.
Timeout default: 1.5 s (MxAccessCommandExecutor.DefaultWriteCompletionTimeout).
The consumer-side budget drives this: OtOpcUa wraps the driver write in a Tier A
resilience policy with a 2 s timeout and a 5-failure breaker, so a worker wait longer
than 2 s would convert slow-but-successful commits into consumer-side false failures
(node revert + breaker pressure). 1.5 s covers the common fast-commit case and degrades
a slow commit to the honest-unconfirmed path instead. It also sits well under the
gateway's 30 s DefaultCommandTimeoutSeconds IPC wait and the STA watchdog's 75 s
dispatched-command ceiling.
The wait is deployment-configurable end to end, following the existing
pipe-connect-timeout pattern: a new gateway option
MxGateway:Worker:WriteCompletionWaitMilliseconds (default 1500, validated >= 0;
0 disables the wait and restores pure fire-and-forget replies) is exported by
WorkerProcessLauncher as the MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS environment
variable, which the worker reads at session construction. A deployment that raises the
gateway wait must raise the OtOpcUa driver's Write ResilienceConfig timeout in step
(per-instance operator config on the OtOpcUa side) — documented in
docs/GatewayConfiguration.md. MxAccessStaSession additionally gets an internal
WriteCompletionTimeout seam (read when it constructs the executor in StartAsync) so
tests can shorten it without env plumbing.
Client cancellation mid-wait needs no new code path, only verification: when the caller
cancels the unary RPC, the gateway abandons its IPC reply wait, but the worker command
is already in flight — CancelCommand only dequeues queued commands. The executor
simply finishes its bounded wait and replies; the gateway discards the reply. The
session is never faulted and the OnWriteComplete event still flows on the stream.
Scope
- In:
WriteSecured,WriteSecured2(single-item secured writes — inherently low-rate operator actions, and exactly the OtOpcUa single-write contract). Default-on. - Out: plain
Write/Write2and all bulk write commands stay fire-and-forget — waiting would add a device round-trip of latency to high-rate supervisory write loops. - Correlation fidelity is best-effort: the MXAccess callback carries only
(hItem, statuses)— no transaction id — so a concurrent write to the same item within the wait window can be attributed to the wrong writer (worst case two writes to the same item swap status rows — benign for the serialized single-write consumer contract). Documented on the proto field.
Error handling
- Completion never arrives (device down): bounded 1.5 s wait, then today's reply shape.
- Event queue overflow during completion: the queue records a fault and the fail-fast design tears the session down; the post-publish hook not firing in that case is moot.
- COM call throws: unchanged — the dispatcher's existing exception path replies with the native HResult; no wait is entered.
Testing (Worker.Tests, x86 — verified on windev)
MxAccessWriteCompletionCacheTests: record/version monotonicity, wait success, deadline expiry, baseline-before-record fast-completion ordering.MxAccessBaseEventSinkTests:OnWriteCompleteboth enqueues the event and records the completion; cache instance is the sink-bound one.MxAccessCommandExecutorTests(viaMxAccessStaSession.DispatchAsync+ fake COM object + test sink implementingIWriteCompletionCacheProvider):- completion recorded synchronously inside the fake's
WriteSecured(fast edge) → reply carriesstatuses[0]; - completion recorded from the test thread while the executor pump-waits → reply
carries
statuses[0]; - no completion + shortened timeout → protocol OK, empty statuses;
WriteSecured2mirrors; plainWritedoes not wait.
- completion recorded synchronously inside the fake's
- Gateway tests (macOS-runnable):
GatewayOptionsValidatoraccepts>= 0and rejects negativeWriteCompletionWaitMilliseconds;WorkerProcessLauncherexportsMXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MSfrom the option (mirroring the existing pipe-connect-timeout env assertion).
Docs in the same change
mxaccess_gateway.proto: comment onMxCommandReply.statusesand theWriteSecuredCommand/WriteSecured2Commandmessages describing the correlated completion contract (populated within the wait window; empty = unconfirmed; best-effort correlation). Comment-only → wire-identical; regenerate + commitContracts/Generated(required for the C# build); other clients' generated code is functionally unchanged.docs/GatewayConfiguration.md: the newMxGateway:Worker:WriteCompletionWaitMillisecondsoption, including the pairing rule with the OtOpcUa driver's Write resilience timeout.gateway.mdcommand/event surface note;docs/DesignDecisions.mdentry.