170 lines
9.8 KiB
Markdown
170 lines
9.8 KiB
Markdown
# 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
|
|
|
|
1. **Executor pump-wait + versioned completion cache (chosen).** After the `WriteSecured`
|
|
COM 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 shipped `ReadBulk` pattern (`MxAccessValueCache.TryWaitForUpdate` +
|
|
`pumpStep` → `StaRuntime.PumpPendingMessages()`), so it adds no new threading model.
|
|
2. **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.
|
|
3. **Gateway-side correlation.** Gateway watches the session event stream for the
|
|
`OnWriteComplete` after 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 absent
|
|
- `bool TryWaitForCompletion(int serverHandle, int itemHandle, ulong sinceVersion,
|
|
DateTime deadlineUtc, Action pumpStep, out RepeatedField<MxStatusProxy> statuses,
|
|
int pollIntervalMs = 5)` — pump/poll loop identical in shape to
|
|
`MxAccessValueCache.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`:
|
|
|
|
1. 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.
|
|
2. Call `session.WriteSecured(...)` as today.
|
|
3. `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`/`Write2` and 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`: `OnWriteComplete` both enqueues the event *and* records
|
|
the completion; cache instance is the sink-bound one.
|
|
- `MxAccessCommandExecutorTests` (via `MxAccessStaSession.DispatchAsync` + fake COM
|
|
object + test sink implementing `IWriteCompletionCacheProvider`):
|
|
- completion recorded synchronously inside the fake's `WriteSecured` (fast edge) →
|
|
reply carries `statuses[0]`;
|
|
- completion recorded from the test thread while the executor pump-waits → reply
|
|
carries `statuses[0]`;
|
|
- no completion + shortened timeout → protocol OK, empty statuses;
|
|
- `WriteSecured2` mirrors; plain `Write` does not wait.
|
|
- Gateway tests (macOS-runnable): `GatewayOptionsValidator` accepts `>= 0` and rejects
|
|
negative `WriteCompletionWaitMilliseconds`; `WorkerProcessLauncher` exports
|
|
`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` from the option (mirroring the existing
|
|
pipe-connect-timeout env assertion).
|
|
|
|
## Docs in the same change
|
|
|
|
- `mxaccess_gateway.proto`: comment on `MxCommandReply.statuses` and the
|
|
`WriteSecuredCommand`/`WriteSecured2Command` messages describing the correlated
|
|
completion contract (populated within the wait window; empty = unconfirmed;
|
|
best-effort correlation). Comment-only → wire-identical; regenerate + commit
|
|
`Contracts/Generated` (required for the C# build); other clients' generated code is
|
|
functionally unchanged.
|
|
- `docs/GatewayConfiguration.md`: the new `MxGateway:Worker:WriteCompletionWaitMilliseconds`
|
|
option, including the pairing rule with the OtOpcUa driver's Write resilience timeout.
|
|
- `gateway.md` command/event surface note; `docs/DesignDecisions.md` entry.
|