diff --git a/docs/DesignDecisions.md b/docs/DesignDecisions.md index ecb6169..6b14485 100644 --- a/docs/DesignDecisions.md +++ b/docs/DesignDecisions.md @@ -534,6 +534,48 @@ against the live MXAccess attribute set. - [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md) - [gRPC Contract — provider_status and degraded fields](./Grpc.md) +## Secured-Write Completion Correlation + +MXAccess writes are fire-and-forget: the toolkit call returns before the +Galaxy commit, and the per-item outcome only exists in the later +`OnWriteComplete` COM callback. The original unary write reply therefore +proved worker-side command acceptance only, forcing consumers (OtOpcUa's +GalaxyDriver) to report every write as provisionally good. + +For `WriteSecured`/`WriteSecured2` the worker now holds the unary reply for a +bounded window (`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default +1.5 s, `0` disables; conveyed to the worker via +`MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS`) and copies the matching +callback's status rows onto `MxCommandReply.statuses`. Key choices, argued in +[the design doc](./plans/2026-08-09-write-completion-correlation-design.md): + +- **Pump-wait on the STA, not a parked reply.** The executor holds the STA + thread but pumps Windows messages each poll — the shipped ReadBulk pattern — + because commands serialize per session anyway, so freeing the STA during the + wait buys nothing and a parked reply would change the dispatcher/pipe + contracts. +- **Version baseline before the COM call** closes the fast-completion edge: a + callback that dispatches while `WriteSecured` is still on the stack still + correlates. +- **Timeout returns today's shape** (protocol OK, empty statuses): + unconfirmed is honest; a synthesized failure row would trigger consumer-side + write-revert logic on slow-but-successful commits. The 1.5 s default stays + inside OtOpcUa's 2 s Tier A write-resilience budget. +- **Parity preserved.** `protocol_status`/`hresult` keep describing + acceptance; the MXAccess outcome (success or failure) rides only in + `statuses[0]`; the `OnWriteComplete` event still streams unchanged (nothing + swallowed, nothing synthesized). +- **Scope: secured writes only.** Plain `Write`/`Write2` and bulk writes stay + fire-and-forget — the wait would add a device round-trip per write to + high-rate supervisory loops. +- **Best-effort correlation.** The callback carries only + `(hItem, statuses)` — no transaction id — so concurrent writes to the same + item within the window can swap rows; benign for the serialized single-write + consumer contract. +- **Client cancellation needs no special path**: a caller abandoning the RPC + mid-wait leaves the worker to finish its bounded wait and reply; the gateway + discards the reply, the session is never faulted. + ## Later Revisit Items These are explicit post-v1 revisit items, not open blockers: diff --git a/docs/GatewayConfiguration.md b/docs/GatewayConfiguration.md index f77e08f..58ca7a3 100644 --- a/docs/GatewayConfiguration.md +++ b/docs/GatewayConfiguration.md @@ -114,6 +114,7 @@ launch CWD (SEC-01, SEC-33). | `MxGateway:Worker:StartupProbeRetryAttempts` | `3` | Number of retry attempts for transient worker startup probe failures before pipe connection and handshake continue. | | `MxGateway:Worker:StartupProbeRetryDelayMilliseconds` | `250` | Delay between transient startup probe retry attempts. | | `MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds` | `2000` | Per-attempt timeout used by the worker named-pipe connect retry path. The overall pipe connection still stays under the startup budget. | +| `MxGateway:Worker:WriteCompletionWaitMilliseconds` | `1500` | Bounded wait the worker holds a `WriteSecured`/`WriteSecured2` reply for the matching MXAccess `OnWriteComplete` callback, so the reply's `statuses` carry the real commit outcome. `0` disables the wait (pure fire-and-forget replies). Must be `>= 0`. The gateway conveys the value to the worker via the `MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS` environment variable. Consumers that time their own writes must budget above this wait: OtOpcUa's GalaxyDriver wraps gateway writes in a 2 s Tier A resilience timeout, so a deployment raising this option past ~2000 must raise that driver `ResilienceConfig` write timeout in step or slow-but-successful commits surface as consumer-side failures. | | `MxGateway:Worker:ShutdownTimeoutSeconds` | `10` | Grace period for worker shutdown before the gateway treats shutdown as failed and may kill the worker process tree. | | `MxGateway:Worker:HeartbeatIntervalSeconds` | `5` | Worker heartbeat send interval and gateway heartbeat check cadence input. | | `MxGateway:Worker:HeartbeatGraceSeconds` | `15` | Maximum age of the last worker heartbeat before the gateway faults the worker. This must be greater than or equal to `HeartbeatIntervalSeconds`. | diff --git a/gateway.md b/gateway.md index 460f120..0f68da5 100644 --- a/gateway.md +++ b/gateway.md @@ -431,6 +431,23 @@ Core commands: - `AuthenticateUser` - `ArchestrAUserToId` +**Secured-write completion correlation.** MXAccess writes are fire-and-forget +at the toolkit level — the per-item outcome only exists in the later +`OnWriteComplete` callback. For `WriteSecured` and `WriteSecured2` the worker +therefore holds the unary reply for a bounded window +(`MxGateway:Worker:WriteCompletionWaitMilliseconds`, default 1.5 s, `0` +disables) and, when the matching callback arrives, copies its status rows onto +`MxCommandReply.statuses` — the reply then proves the MXAccess commit, not just +command acceptance. `protocol_status`/`hresult` keep describing acceptance +only; a real MXAccess write failure surfaces in `statuses[0]`, and a reply with +empty `statuses` means unconfirmed (the callback missed the window), never +failed. The `OnWriteComplete` event still flows on the event stream unchanged. +Correlation is best-effort per `(server_handle, item_handle)` — the callback +carries no transaction id, so concurrent writes to the same item within the +window can swap rows. Plain `Write`/`Write2` and the bulk write commands stay +fire-and-forget: waiting there would add a device round-trip of latency to +high-rate supervisory write loops. + Bulk variants (single gRPC round-trip carries the full list, the worker runs the per-item MXAccess calls sequentially on its STA, and the reply returns one result per requested entry — per-entry failures populate