diff --git a/clients/go/internal/generated/mxaccess_gateway.pb.go b/clients/go/internal/generated/mxaccess_gateway.pb.go index d3733d6..2064cc0 100644 --- a/clients/go/internal/generated/mxaccess_gateway.pb.go +++ b/clients/go/internal/generated/mxaccess_gateway.pb.go @@ -2697,6 +2697,9 @@ func (x *Write2Command) GetUserId() int32 { return 0 } +// The unary reply's statuses field carries the correlated OnWriteComplete +// outcome when it arrives within the worker's bounded wait — see +// MxCommandReply.statuses. type WriteSecuredCommand struct { state protoimpl.MessageState `protogen:"open.v1"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` @@ -2775,6 +2778,9 @@ func (x *WriteSecuredCommand) GetValue() *MxValue { return nil } +// The unary reply's statuses field carries the correlated OnWriteComplete +// outcome when it arrives within the worker's bounded wait — see +// MxCommandReply.statuses. type WriteSecured2Command struct { state protoimpl.MessageState `protogen:"open.v1"` ServerHandle int32 `protobuf:"varint,1,opt,name=server_handle,json=serverHandle,proto3" json:"server_handle,omitempty"` @@ -4575,8 +4581,20 @@ type MxCommandReply struct { // HRESULT captured from MXAccess or a COM exception. This remains separate // from gateway protocol status so MXAccess parity details are not hidden by // transport failures. - Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"` - ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"` + Hresult *int32 `protobuf:"varint,5,opt,name=hresult,proto3,oneof" json:"hresult,omitempty"` + ReturnValue *MxValue `protobuf:"bytes,6,opt,name=return_value,json=returnValue,proto3" json:"return_value,omitempty"` + // Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2 + // replies the worker holds the reply for a bounded window (default 1.5 s, + // MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching + // MXAccess OnWriteComplete callback and copies its status rows here, so + // statuses[0] carries the real MXAccess commit outcome (success OR failure) + // while protocol_status/hresult still describe command acceptance only. + // Empty statuses on a write reply means the completion did not arrive + // within the window — the write is unconfirmed, not failed. Correlation is + // best-effort per (server_handle, item_handle): MXAccess's callback carries + // no transaction id, so concurrent writes to the same item within the + // window can swap rows. The OnWriteComplete event still flows on the event + // stream unchanged. Other command kinds leave this field as before. Statuses []*MxStatusProxy `protobuf:"bytes,7,rep,name=statuses,proto3" json:"statuses,omitempty"` DiagnosticMessage string `protobuf:"bytes,8,opt,name=diagnostic_message,json=diagnosticMessage,proto3" json:"diagnostic_message,omitempty"` // Types that are valid to be assigned to Payload: diff --git a/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java b/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java index 7271063..a69750f 100644 --- a/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java +++ b/clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java @@ -26028,6 +26028,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getValueOrBuilder(); } /** + *
+   * The unary reply's statuses field carries the correlated OnWriteComplete
+   * outcome when it arrives within the worker's bounded wait — see
+   * MxCommandReply.statuses.
+   * 
+ * * Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand} */ public static final class WriteSecuredCommand extends @@ -26357,6 +26363,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return builder; } /** + *
+     * The unary reply's statuses field carries the correlated OnWriteComplete
+     * outcome when it arrives within the worker's bounded wait — see
+     * MxCommandReply.statuses.
+     * 
+ * * Protobuf type {@code mxaccess_gateway.v1.WriteSecuredCommand} */ public static final class Builder extends @@ -26976,6 +26988,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getTimestampValueOrBuilder(); } /** + *
+   * The unary reply's statuses field carries the correlated OnWriteComplete
+   * outcome when it arrives within the worker's bounded wait — see
+   * MxCommandReply.statuses.
+   * 
+ * * Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command} */ public static final class WriteSecured2Command extends @@ -27347,6 +27365,12 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return builder; } /** + *
+     * The unary reply's statuses field carries the correlated OnWriteComplete
+     * outcome when it arrives within the worker's bounded wait — see
+     * MxCommandReply.statuses.
+     * 
+ * * Protobuf type {@code mxaccess_gateway.v1.WriteSecured2Command} */ public static final class Builder extends @@ -50488,24 +50512,99 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { mxaccess_gateway.v1.MxaccessGateway.MxValueOrBuilder getReturnValueOrBuilder(); /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ java.util.List getStatusesList(); /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index); /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ int getStatusesCount(); /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ java.util.List getStatusesOrBuilderList(); /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder( @@ -51233,6 +51332,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { @SuppressWarnings("serial") private java.util.List statuses_; /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ @java.lang.Override @@ -51240,6 +51354,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return statuses_; } /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ @java.lang.Override @@ -51248,6 +51377,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return statuses_; } /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ @java.lang.Override @@ -51255,6 +51399,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return statuses_.size(); } /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ @java.lang.Override @@ -51262,6 +51421,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return statuses_.get(index); } /** + *
+     * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+     * replies the worker holds the reply for a bounded window (default 1.5 s,
+     * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+     * MXAccess OnWriteComplete callback and copies its status rows here, so
+     * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+     * while protocol_status/hresult still describe command acceptance only.
+     * Empty statuses on a write reply means the completion did not arrive
+     * within the window — the write is unconfirmed, not failed. Correlation is
+     * best-effort per (server_handle, item_handle): MXAccess's callback carries
+     * no transaction id, so concurrent writes to the same item within the
+     * window can swap rows. The OnWriteComplete event still flows on the event
+     * stream unchanged. Other command kinds leave this field as before.
+     * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ @java.lang.Override @@ -53981,6 +54155,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder> statusesBuilder_; /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public java.util.List getStatusesList() { @@ -53991,6 +54180,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public int getStatusesCount() { @@ -54001,6 +54205,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy getStatuses(int index) { @@ -54011,6 +54230,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder setStatuses( @@ -54028,6 +54262,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder setStatuses( @@ -54042,6 +54291,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder addStatuses(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy value) { @@ -54058,6 +54322,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder addStatuses( @@ -54075,6 +54354,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder addStatuses( @@ -54089,6 +54383,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder addStatuses( @@ -54103,6 +54412,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder addAllStatuses( @@ -54118,6 +54442,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder clearStatuses() { @@ -54131,6 +54470,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public Builder removeStatuses(int index) { @@ -54144,6 +54498,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return this; } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder getStatusesBuilder( @@ -54151,6 +54520,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { return internalGetStatusesFieldBuilder().getBuilder(index); } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxyOrBuilder getStatusesOrBuilder( @@ -54161,6 +54545,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public java.util.List @@ -54172,6 +54571,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { } } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder() { @@ -54179,6 +54593,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance()); } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.Builder addStatusesBuilder( @@ -54187,6 +54616,21 @@ public final class MxaccessGateway extends com.google.protobuf.GeneratedFile { index, mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.getDefaultInstance()); } /** + *
+       * Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2
+       * replies the worker holds the reply for a bounded window (default 1.5 s,
+       * MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching
+       * MXAccess OnWriteComplete callback and copies its status rows here, so
+       * statuses[0] carries the real MXAccess commit outcome (success OR failure)
+       * while protocol_status/hresult still describe command acceptance only.
+       * Empty statuses on a write reply means the completion did not arrive
+       * within the window — the write is unconfirmed, not failed. Correlation is
+       * best-effort per (server_handle, item_handle): MXAccess's callback carries
+       * no transaction id, so concurrent writes to the same item within the
+       * window can swap rows. The OnWriteComplete event still flows on the event
+       * stream unchanged. Other command kinds leave this field as before.
+       * 
+ * * repeated .mxaccess_gateway.v1.MxStatusProxy statuses = 7; */ public java.util.List diff --git a/clients/rust/protos/mxaccess_gateway.proto b/clients/rust/protos/mxaccess_gateway.proto index be96d5a..ee923b5 100644 --- a/clients/rust/protos/mxaccess_gateway.proto +++ b/clients/rust/protos/mxaccess_gateway.proto @@ -256,6 +256,9 @@ message Write2Command { int32 user_id = 5; } +// The unary reply's statuses field carries the correlated OnWriteComplete +// outcome when it arrives within the worker's bounded wait — see +// MxCommandReply.statuses. message WriteSecuredCommand { int32 server_handle = 1; int32 item_handle = 2; @@ -266,6 +269,9 @@ message WriteSecuredCommand { MxValue value = 5; } +// The unary reply's statuses field carries the correlated OnWriteComplete +// outcome when it arrives within the worker's bounded wait — see +// MxCommandReply.statuses. message WriteSecured2Command { int32 server_handle = 1; int32 item_handle = 2; @@ -525,6 +531,18 @@ message MxCommandReply { // transport failures. optional int32 hresult = 5; MxValue return_value = 6; + // Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2 + // replies the worker holds the reply for a bounded window (default 1.5 s, + // MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching + // MXAccess OnWriteComplete callback and copies its status rows here, so + // statuses[0] carries the real MXAccess commit outcome (success OR failure) + // while protocol_status/hresult still describe command acceptance only. + // Empty statuses on a write reply means the completion did not arrive + // within the window — the write is unconfirmed, not failed. Correlation is + // best-effort per (server_handle, item_handle): MXAccess's callback carries + // no transaction id, so concurrent writes to the same item within the + // window can swap rows. The OnWriteComplete event still flows on the event + // stream unchanged. Other command kinds leave this field as before. repeated MxStatusProxy statuses = 7; string diagnostic_message = 8; 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 diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs index f78dc5b..400e221 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Generated/MxaccessGateway.cs @@ -8694,6 +8694,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } + /// + /// The unary reply's statuses field carries the correlated OnWriteComplete + /// outcome when it arrives within the worker's bounded wait — see + /// MxCommandReply.statuses. + /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class WriteSecuredCommand : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE @@ -9053,6 +9058,11 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { } + /// + /// The unary reply's statuses field carries the correlated OnWriteComplete + /// outcome when it arrives within the worker's bounded wait — see + /// MxCommandReply.statuses. + /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] public sealed partial class WriteSecured2Command : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE @@ -17204,6 +17214,20 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto { private static readonly pb::FieldCodec _repeated_statuses_codec = pb::FieldCodec.ForMessage(58, global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusProxy.Parser); private readonly pbc::RepeatedField statuses_ = new pbc::RepeatedField(); + /// + /// Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2 + /// replies the worker holds the reply for a bounded window (default 1.5 s, + /// MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching + /// MXAccess OnWriteComplete callback and copies its status rows here, so + /// statuses[0] carries the real MXAccess commit outcome (success OR failure) + /// while protocol_status/hresult still describe command acceptance only. + /// Empty statuses on a write reply means the completion did not arrive + /// within the window — the write is unconfirmed, not failed. Correlation is + /// best-effort per (server_handle, item_handle): MXAccess's callback carries + /// no transaction id, so concurrent writes to the same item within the + /// window can swap rows. The OnWriteComplete event still flows on the event + /// stream unchanged. Other command kinds leave this field as before. + /// [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public pbc::RepeatedField Statuses { diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto index be96d5a..ee923b5 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto +++ b/src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto @@ -256,6 +256,9 @@ message Write2Command { int32 user_id = 5; } +// The unary reply's statuses field carries the correlated OnWriteComplete +// outcome when it arrives within the worker's bounded wait — see +// MxCommandReply.statuses. message WriteSecuredCommand { int32 server_handle = 1; int32 item_handle = 2; @@ -266,6 +269,9 @@ message WriteSecuredCommand { MxValue value = 5; } +// The unary reply's statuses field carries the correlated OnWriteComplete +// outcome when it arrives within the worker's bounded wait — see +// MxCommandReply.statuses. message WriteSecured2Command { int32 server_handle = 1; int32 item_handle = 2; @@ -525,6 +531,18 @@ message MxCommandReply { // transport failures. optional int32 hresult = 5; MxValue return_value = 6; + // Correlated per-item outcome rows. For WRITE_SECURED / WRITE_SECURED2 + // replies the worker holds the reply for a bounded window (default 1.5 s, + // MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS) waiting for the matching + // MXAccess OnWriteComplete callback and copies its status rows here, so + // statuses[0] carries the real MXAccess commit outcome (success OR failure) + // while protocol_status/hresult still describe command acceptance only. + // Empty statuses on a write reply means the completion did not arrive + // within the window — the write is unconfirmed, not failed. Correlation is + // best-effort per (server_handle, item_handle): MXAccess's callback carries + // no transaction id, so concurrent writes to the same item within the + // window can swap rows. The OnWriteComplete event still flows on the event + // stream unchanged. Other command kinds leave this field as before. repeated MxStatusProxy statuses = 7; string diagnostic_message = 8; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs index 05a6967..2d18295 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs @@ -225,6 +225,10 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBaseThe timeout in milliseconds for connecting to the worker pipe. public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000; + /// + /// Bounded wait, in milliseconds, 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. Deployments + /// raising this above consumer write-timeout budgets (e.g. OtOpcUa's 2 s Tier A + /// write resilience timeout) must raise those in step. + /// + public int WriteCompletionWaitMilliseconds { get; init; } = 1500; + /// The maximum time in seconds for graceful shutdown. public int ShutdownTimeoutSeconds { get; init; } = 10; diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs index cab6fe1..b74f85a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerProcessLauncher.cs @@ -21,6 +21,14 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher public const string WorkerPipeConnectAttemptTimeoutEnvironmentVariableName = "MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS"; + /// + /// Conveys MxGateway:Worker:WriteCompletionWaitMilliseconds to the worker: + /// the bounded wait for the OnWriteComplete callback on + /// WriteSecured/WriteSecured2 replies. 0 disables the wait. + /// + public const string WorkerWriteCompletionWaitEnvironmentVariableName = + "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS"; + private readonly IWorkerProcessFactory _processFactory; private readonly IWorkerStartupProbe _startupProbe; private readonly GatewayMetrics _metrics; @@ -175,6 +183,8 @@ public sealed class WorkerProcessLauncher : IWorkerProcessLauncher startInfo.Environment[WorkerNonceEnvironmentVariableName] = request.Nonce; startInfo.Environment[WorkerPipeConnectAttemptTimeoutEnvironmentVariableName] = _workerOptions.PipeConnectAttemptTimeoutMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + startInfo.Environment[WorkerWriteCompletionWaitEnvironmentVariableName] = + _workerOptions.WriteCompletionWaitMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture); commandLine = new WorkerProcessCommandLine(executablePath, arguments); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs index e05e93b..1506edc 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs @@ -41,6 +41,7 @@ public sealed class GatewayOptionsTests Assert.Equal(3, options.Worker.StartupProbeRetryAttempts); Assert.Equal(250, options.Worker.StartupProbeRetryDelayMilliseconds); Assert.Equal(2000, options.Worker.PipeConnectAttemptTimeoutMilliseconds); + Assert.Equal(1500, options.Worker.WriteCompletionWaitMilliseconds); Assert.Equal(10, options.Worker.ShutdownTimeoutSeconds); Assert.Equal(5, options.Worker.HeartbeatIntervalSeconds); Assert.Equal(15, options.Worker.HeartbeatGraceSeconds); @@ -106,6 +107,7 @@ public sealed class GatewayOptionsTests [InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")] [InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")] [InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")] + [InlineData("MxGateway:Worker:WriteCompletionWaitMilliseconds", "-1", "MxGateway:Worker:WriteCompletionWaitMilliseconds must be greater than or equal to zero.")] [InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")] [InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")] [InlineData("MxGateway:Sessions:DetachGraceSeconds", "-1", "MxGateway:Sessions:DetachGraceSeconds must be zero or greater (0 disables detach-grace retention).")] diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs index f9d0e43..52b3756 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerProcessLauncherTests.cs @@ -43,6 +43,10 @@ public sealed class WorkerProcessLauncherTests "2000", processFactory.LastStartInfo.Environment[ WorkerProcessLauncher.WorkerPipeConnectAttemptTimeoutEnvironmentVariableName]); + Assert.Equal( + "1500", + processFactory.LastStartInfo.Environment[ + WorkerProcessLauncher.WorkerWriteCompletionWaitEnvironmentVariableName]); Assert.DoesNotContain(Nonce, handle.CommandLine.ToString(), StringComparison.Ordinal); Assert.DoesNotContain(Nonce, string.Join(" ", handle.CommandLine.Arguments), StringComparison.Ordinal); Assert.False(pipeReservation.DisposeCalled); diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs index 935e9d2..a81362e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessBaseEventSinkTests.cs @@ -110,6 +110,47 @@ public sealed class MxAccessBaseEventSinkTests Assert.Same(cache, sink.ValueCache); } + /// + /// Verifies that an OnWriteComplete COM callback records the completion into + /// the per-session write-completion cache for the executor's bounded reply + /// wait AND still enqueues the event unchanged for the outbound stream — + /// correlation observes the event, it never consumes it. The cache update + /// fires only after the event has cleared the queue (post-publish rule). + /// + [Fact] + public void OnWriteComplete_ComCallback_RecordsCompletionAndStillEnqueuesEvent() + { + MxAccessEventQueue queue = new(); + MxAccessWriteCompletionCache completionCache = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), new MxAccessValueCache(), completionCache); + MXSTATUS_PROXY[] statuses = Array.Empty(); + + sink.OnWriteComplete(hLMXServerHandle: 7, phItemHandle: 21, ref statuses); + + Assert.Equal(1, queue.Count); + Assert.True(queue.TryDequeue(out WorkerEvent? workerEvent)); + MxEvent mxEvent = workerEvent!.Event; + Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family); + Assert.Equal(7, mxEvent.ServerHandle); + Assert.Equal(21, mxEvent.ItemHandle); + Assert.Equal(1UL, completionCache.CurrentVersion(7, 21)); + } + + /// + /// Verifies that the sink-bound write-completion cache is exposed for sharing + /// with the owning so the sink's recordings and + /// the write executor's waits see the same instance. + /// + [Fact] + public void WriteCompletionCache_ReturnsTheInstanceBoundAtConstruction() + { + MxAccessEventQueue queue = new(); + MxAccessWriteCompletionCache completionCache = new(); + MxAccessBaseEventSink sink = new(queue, new MxAccessEventMapper(), new MxAccessValueCache(), completionCache); + + Assert.Same(completionCache, sink.WriteCompletionCache); + } + /// /// Verifies that consecutive OnDataChange callbacks land in the queue with monotonic sequences. /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs index 2b4b0aa..34f9406 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs @@ -897,6 +897,9 @@ public sealed class MxAccessCommandExecutorTests FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + // No completion source in this test — disable the bounded reply wait + // so the forwarding assertions don't pay the default 1.5 s timeout. + session.WriteCompletionTimeout = TimeSpan.Zero; await session.StartAsync(workerProcessId: 1234); MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( @@ -919,6 +922,8 @@ public sealed class MxAccessCommandExecutorTests FakeMxAccessComObjectFactory factory = new(fakeComObject); using StaRuntime runtime = CreateRuntime(); using MxAccessStaSession session = new(runtime, factory, new NoopEventSink()); + // Same rationale as the WriteSecured forwarding test above. + session.WriteCompletionTimeout = TimeSpan.Zero; await session.StartAsync(workerProcessId: 1234); DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc); @@ -934,6 +939,192 @@ public sealed class MxAccessCommandExecutorTests Assert.Equal(44, fakeComObject.WriteVerifierUserId); } + /// + /// Verifies the fast-completion ordering edge: a completion recorded while + /// the WriteSecured COM call is still on the stack (MXAccess committing + /// synchronously) is newer than the pre-call baseline and lands on the + /// reply — the wait never misses a callback that beat it. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 82); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + fakeComObject.OnWriteSecuredCallback = () => + sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321)); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + // Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS + // from the test runner's environment. + session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-fast", serverHandle: 82, itemHandle: 820, value: 1, currentUserId: 11, verifierUserId: 22)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(0, reply.Hresult); + MxStatusProxy row = Assert.Single(reply.Statuses); + Assert.Equal(4321, row.Detail); + Assert.Equal(MxStatusCategory.Ok, row.Category); + } + + /// + /// Verifies the pump-wait path: the completion arrives after the COM call + /// returned, while the executor is pump-waiting, and still lands on the + /// reply. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 83); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + // Deterministic ordering: the executor captures its version baseline + // BEFORE the COM call, so once the fake's WriteSecured has run the + // baseline is committed and a Record from the test thread is + // guaranteed to be "newer" — no fixed sleep racing the STA thread. + using System.Threading.ManualResetEventSlim comCallReached = new(initialState: false); + fakeComObject.OnWriteSecuredCallback = () => comCallReached.Set(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); + await session.StartAsync(workerProcessId: 1234); + + Task pending = session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-waiting", serverHandle: 83, itemHandle: 830, value: 1, currentUserId: 11, verifierUserId: 22)); + Assert.True(comCallReached.Wait(TimeSpan.FromSeconds(5))); + sink.WriteCompletionCache.Record(83, 830, CreateCompletionRows(detail: 99)); + + MxCommandReply reply = await pending; + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(99, Assert.Single(reply.Statuses).Detail); + } + + /// + /// Verifies the timeout fallback: no completion within the bounded wait + /// returns today's reply shape — protocol OK with EMPTY statuses (the + /// consumer's honest-unconfirmed path), never a synthesized failure row. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 84); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-timeout", serverHandle: 84, itemHandle: 840, value: 1, currentUserId: 11, verifierUserId: 22)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.True(reply.HasHresult); + Assert.Equal(0, reply.Hresult); + Assert.Empty(reply.Statuses); + } + + /// + /// Verifies the version-baseline rule end to end: a completion recorded + /// BEFORE the write was dispatched is stale and must not be misattributed + /// to this write — the reply times out empty instead. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 85); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + sink.WriteCompletionCache.Record(85, 850, CreateCompletionRows(detail: 1111)); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand( + "write-secured-stale", serverHandle: 85, itemHandle: 850, value: 1, currentUserId: 11, verifierUserId: 22)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Empty(reply.Statuses); + } + + /// + /// Verifies that WriteSecured2 correlates the same way as WriteSecured + /// (fast-completion edge). + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 86); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + fakeComObject.OnWriteSecuredCallback = () => + sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222)); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + // Hermetic: same rationale as the WriteSecured fast-completion test. + session.WriteCompletionTimeout = TimeSpan.FromSeconds(10); + await session.StartAsync(workerProcessId: 1234); + + MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command( + "write-secured2-fast", serverHandle: 86, itemHandle: 860, value: 1, + timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), currentUserId: 33, verifierUserId: 44)); + + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Equal(2222, Assert.Single(reply.Statuses).Detail); + } + + /// + /// Verifies plain Write stays fire-and-forget: even with a huge completion + /// timeout configured and no completion source, the reply returns + /// immediately (guarded well under the configured wait) with empty + /// statuses — only the secured write kinds enter the bounded wait. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task DispatchAsync_Write_DoesNotWaitForCompletion() + { + FakeMxAccessComObject fakeComObject = new(registerHandle: 87); + FakeMxAccessComObjectFactory factory = new(fakeComObject); + CompletionCacheEventSink sink = new(); + using StaRuntime runtime = CreateRuntime(); + using MxAccessStaSession session = new(runtime, factory, sink); + session.WriteCompletionTimeout = TimeSpan.FromSeconds(30); + await session.StartAsync(workerProcessId: 1234); + + Task pending = session.DispatchAsync(CreateWriteCommand( + "plain-write-no-wait", serverHandle: 87, itemHandle: 870, value: 1, userId: 5)); + Task completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5))); + + Assert.Same(pending, completed); + MxCommandReply reply = await pending; + Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code); + Assert.Empty(reply.Statuses); + } + + private static Google.Protobuf.Collections.RepeatedField CreateCompletionRows(int detail) + { + return new Google.Protobuf.Collections.RepeatedField + { + new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + Detail = detail, + }, + }; + } + /// Verifies that Write without a payload returns an invalid request error. /// A task that represents the asynchronous operation. [Fact] @@ -1775,6 +1966,28 @@ public sealed class MxAccessCommandExecutorTests TimeSpan.FromMilliseconds(25)); } + /// + /// Test sink that owns a real write-completion cache without touching the + /// MXAccess COM RCW (Attach is a no-op). Implements the provider seam so + /// shares this cache with the write + /// executor, letting tests record completions the executor's bounded + /// wait then observes. + /// + private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider + { + public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache(); + + public void Attach( + object mxAccessComObject, + string sessionId) + { + } + + public void Detach() + { + } + } + private sealed class FakeMxAccessComObject : IMxAccessServer { private readonly int registerHandle; @@ -1790,6 +2003,14 @@ public sealed class MxAccessCommandExecutorTests private readonly IReadOnlyDictionary writeExceptionByItemHandle; private readonly List operationNames = new(); + /// + /// Invoked at the end of a successful WriteSecured/WriteSecured2 — + /// stands in for MXAccess committing synchronously and delivering + /// OnWriteComplete while the COM call is still on the stack, so + /// tests can exercise the fast-completion ordering edge. + /// + public Action? OnWriteSecuredCallback { get; set; } + /// Initializes a fake MXAccess COM object with the given handles and optional exceptions. /// Return value for Register method. /// Return value for AddItem method. @@ -2100,6 +2321,7 @@ public sealed class MxAccessCommandExecutorTests WriteValue = value; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); + OnWriteSecuredCallback?.Invoke(); } /// @@ -2120,6 +2342,7 @@ public sealed class MxAccessCommandExecutorTests WriteTimestamp = timestamp; WriteThreadId = Environment.CurrentManagedThreadId; ThrowIfWriteFailureConfigured(itemHandle); + OnWriteSecuredCallback?.Invoke(); } private void ThrowIfWriteFailureConfigured(int itemHandle) diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs index a8002b3..44894ea 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessStaSessionTests.cs @@ -15,6 +15,48 @@ namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; /// public sealed class MxAccessStaSessionTests { + /// + /// Verifies the launcher-env-var parse branches of + /// : + /// a valid non-negative value is honored (0 = disabled), while a + /// missing, malformed, or negative value falls back to the executor + /// default. Env mutation is restored in a finally so parallel tests + /// never observe the temporary value. + /// + /// Raw env-var value, or null for unset. + /// Expected resolved wait, or null for the executor default. + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("junk", null)] + [InlineData("-5", null)] + [InlineData("0", 0)] + [InlineData("250", 250)] + public void ResolveWriteCompletionTimeout_ParsesEnvironmentValue(string? rawValue, int? expectedMilliseconds) + { + string? original = Environment.GetEnvironmentVariable( + MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName); + try + { + Environment.SetEnvironmentVariable( + MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName, + rawValue); + + TimeSpan resolved = MxAccessStaSession.ResolveWriteCompletionTimeout(); + + TimeSpan expected = expectedMilliseconds is null + ? MxAccessCommandExecutor.DefaultWriteCompletionTimeout + : TimeSpan.FromMilliseconds(expectedMilliseconds.Value); + Assert.Equal(expected, resolved); + } + finally + { + Environment.SetEnvironmentVariable( + MxAccessStaSession.WriteCompletionWaitEnvironmentVariableName, + original); + } + } + /// /// Verifies that StartAsync creates the MXAccess COM object and attaches the event sink on the STA thread. /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs new file mode 100644 index 0000000..670bc5f --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs @@ -0,0 +1,145 @@ +using System; +using Google.Protobuf.Collections; +using ZB.MOM.WW.MxGateway.Contracts.Proto; +using ZB.MOM.WW.MxGateway.Worker.MxAccess; + +namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess; + +/// +/// Unit tests for . The cache is +/// consumed by the write command executor's bounded pump-wait so a +/// WriteSecured/WriteSecured2 reply can carry the correlated +/// OnWriteComplete outcome; its version-baseline contract is exercised in +/// isolation here before the STA / COM plumbing gets layered on top. +/// +public sealed class MxAccessWriteCompletionCacheTests +{ + /// Verifies that Record bumps the version per key and keys stay isolated. + [Fact] + public void Record_IncrementsVersionPerKey() + { + MxAccessWriteCompletionCache cache = new(); + + Assert.Equal(0UL, cache.CurrentVersion(7, 21)); + + cache.Record(7, 21, BuildStatuses(detail: 100)); + Assert.Equal(1UL, cache.CurrentVersion(7, 21)); + + cache.Record(7, 21, BuildStatuses(detail: 200)); + Assert.Equal(2UL, cache.CurrentVersion(7, 21)); + + cache.Record(7, 22, BuildStatuses(detail: 300)); + Assert.Equal(1UL, cache.CurrentVersion(7, 22)); + Assert.Equal(2UL, cache.CurrentVersion(7, 21)); + } + + /// Verifies that a completion newer than the baseline is returned with its status rows. + [Fact] + public void TryWaitForCompletion_WhenCompletionNewerThanBaseline_ReturnsStatuses() + { + MxAccessWriteCompletionCache cache = new(); + cache.Record(7, 21, BuildStatuses(detail: 4321)); + + bool found = cache.TryWaitForCompletion( + 7, + 21, + sinceVersion: 0UL, + deadlineUtc: DateTime.UtcNow.AddSeconds(5), + pumpStep: static () => { }, + out RepeatedField statuses); + + Assert.True(found); + MxStatusProxy row = Assert.Single(statuses); + Assert.Equal(4321, row.Detail); + Assert.Equal(MxStatusCategory.Ok, row.Category); + } + + /// + /// Verifies that a completion recorded before the baseline was captured is + /// never misattributed to the waiting write: only a strictly newer version + /// satisfies the wait, so a stale row times the wait out. + /// + [Fact] + public void TryWaitForCompletion_WhenOnlyStaleCompletion_TimesOut() + { + MxAccessWriteCompletionCache cache = new(); + cache.Record(7, 21, BuildStatuses(detail: 4321)); + ulong baseline = cache.CurrentVersion(7, 21); + + bool found = cache.TryWaitForCompletion( + 7, + 21, + sinceVersion: baseline, + deadlineUtc: DateTime.UtcNow.AddMilliseconds(50), + pumpStep: static () => { }, + out RepeatedField statuses); + + Assert.False(found); + Assert.Empty(statuses); + } + + /// + /// Verifies the pump loop is what lets a completion land: the completion is + /// recorded from inside a later pump step (standing in for the STA + /// dispatching the OnWriteComplete message) and the wait then succeeds. + /// + [Fact] + public void TryWaitForCompletion_InvokesPumpStepEachIteration() + { + MxAccessWriteCompletionCache cache = new(); + int pumpCalls = 0; + + bool found = cache.TryWaitForCompletion( + 7, + 21, + sinceVersion: 0UL, + deadlineUtc: DateTime.UtcNow.AddSeconds(5), + pumpStep: () => + { + pumpCalls++; + if (pumpCalls == 2) + { + cache.Record(7, 21, BuildStatuses(detail: 55)); + } + }, + out RepeatedField statuses); + + Assert.True(found); + Assert.True(pumpCalls >= 2); + Assert.Equal(55, Assert.Single(statuses).Detail); + } + + /// Verifies that Record stores an independent clone of the caller's rows. + [Fact] + public void Record_ClonesStatuses() + { + MxAccessWriteCompletionCache cache = new(); + RepeatedField callerRows = BuildStatuses(detail: 77); + + cache.Record(7, 21, callerRows); + callerRows[0].Detail = 999; + callerRows.Add(new MxStatusProxy()); + + Assert.True(cache.TryWaitForCompletion( + 7, + 21, + sinceVersion: 0UL, + deadlineUtc: DateTime.UtcNow.AddSeconds(5), + pumpStep: static () => { }, + out RepeatedField statuses)); + Assert.Equal(77, Assert.Single(statuses).Detail); + } + + private static RepeatedField BuildStatuses(int detail) + { + return new RepeatedField + { + new MxStatusProxy + { + Success = 1, + Category = MxStatusCategory.Ok, + Detail = detail, + }, + }; + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWriteCompletionCacheProvider.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWriteCompletionCacheProvider.cs new file mode 100644 index 0000000..71509e9 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IWriteCompletionCacheProvider.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Exposes the per-session an +/// event sink populates from OnWriteComplete callbacks, so +/// can share one instance between the +/// sink (writer) and the write command executor (reader). Implemented by +/// and by test sinks that cannot +/// attach to a live MXAccess COM object. +/// +public interface IWriteCompletionCacheProvider +{ + /// The completion cache bound to this sink. + MxAccessWriteCompletionCache WriteCompletionCache { get; } +} diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs index 33c0bfe..d3903c9 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessBaseEventSink.cs @@ -5,11 +5,12 @@ using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto; namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; /// Sink for MXAccess COM events that converts them to protobuf format. -public sealed class MxAccessBaseEventSink : IMxAccessEventSink +public sealed class MxAccessBaseEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider { private readonly MxAccessEventMapper eventMapper; private readonly MxAccessEventQueue eventQueue; private readonly MxAccessValueCache valueCache; + private readonly MxAccessWriteCompletionCache writeCompletionCache; private LMXProxyServerClass? server; private string sessionId = string.Empty; @@ -50,10 +51,32 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink MxAccessEventQueue eventQueue, MxAccessEventMapper eventMapper, MxAccessValueCache valueCache) + : this(eventQueue, eventMapper, valueCache, new MxAccessWriteCompletionCache()) + { + } + + /// + /// Initializes a new instance of the MxAccessBaseEventSink class with + /// provided queue, mapper, value cache, and a shared write-completion + /// cache. The completion cache is populated from every successful + /// OnWriteComplete dispatch so the worker's write executor can + /// correlate a WriteSecured/WriteSecured2 reply with the MXAccess + /// completion outcome. + /// + /// Queue for buffering converted MXAccess events. + /// Converter for MXAccess events to protobuf format. + /// Per-session last-value cache shared with the MxAccessSession. + /// Per-session OnWriteComplete cache shared with the MxAccessSession. + public MxAccessBaseEventSink( + MxAccessEventQueue eventQueue, + MxAccessEventMapper eventMapper, + MxAccessValueCache valueCache, + MxAccessWriteCompletionCache writeCompletionCache) { this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue)); this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper)); this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache)); + this.writeCompletionCache = writeCompletionCache ?? throw new ArgumentNullException(nameof(writeCompletionCache)); } /// @@ -62,6 +85,14 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink /// public MxAccessValueCache ValueCache => valueCache; + /// + /// The OnWriteComplete completion cache populated by this sink. Exposed + /// via so the + /// MxAccessSession can share the same instance with the write command + /// executor's bounded completion wait. + /// + public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache; + /// public void Attach( object mxAccessComObject, @@ -143,11 +174,18 @@ public sealed class MxAccessBaseEventSink : IMxAccessEventSink ref MXSTATUS_PROXY[] pVars) { MXSTATUS_PROXY[] statuses = pVars; - EnqueueEvent(() => eventMapper.CreateOnWriteComplete( - sessionId, - hLMXServerHandle, - phItemHandle, - statuses)); + // Record the completion for the write executor's bounded reply wait + // only after the event has cleared the queue (same post-publish rule + // as the OnDataChange value cache) — an overflow faults the session, + // so a dropped event never leaves a "fresher" completion behind than + // what shipped to the gateway. + EnqueueEvent( + () => eventMapper.CreateOnWriteComplete( + sessionId, + hLMXServerHandle, + phItemHandle, + statuses), + mxEvent => writeCompletionCache.Record(hLMXServerHandle, phItemHandle, mxEvent.Statuses)); } /// diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index da91f4b..6b061bd 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -14,11 +14,22 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor /// Default per-tag timeout used when ReadBulkCommand.timeout_ms is zero. internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000); + /// + /// Default bounded wait for the OnWriteComplete callback after a + /// WriteSecured/WriteSecured2 COM call. 1.5 s keeps the unary reply + /// inside the OtOpcUa driver's 2 s Tier A write-resilience budget (a + /// longer gateway wait must raise that consumer timeout in step) while + /// covering the common fast-commit case; on expiry the reply returns + /// with empty statuses — unconfirmed, not failed. + /// + internal static readonly TimeSpan DefaultWriteCompletionTimeout = TimeSpan.FromMilliseconds(1500); + private readonly MxAccessSession session; private readonly VariantConverter variantConverter; private readonly MxStatusProxyConverter statusProxyConverter; private readonly IAlarmCommandHandler? alarmCommandHandler; private readonly Action pumpStep; + private readonly TimeSpan writeCompletionTimeout; /// /// Initializes a command executor with an MXAccess session. @@ -71,17 +82,26 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor /// Converter for MXAccess variant values to MxValue protobuf messages. /// Optional handler for alarm-side commands. /// Action to pump Windows messages, or null for tests. + /// + /// Bounded wait for the OnWriteComplete callback after a + /// WriteSecured/WriteSecured2 COM call, or null for + /// . Zero (or negative) + /// disables the wait entirely — replies keep the pure fire-and-forget + /// shape. + /// public MxAccessCommandExecutor( MxAccessSession session, VariantConverter variantConverter, IAlarmCommandHandler? alarmCommandHandler, - Action? pumpStep) + Action? pumpStep, + TimeSpan? writeCompletionTimeout = null) { this.session = session ?? throw new ArgumentNullException(nameof(session)); this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter)); this.statusProxyConverter = new MxStatusProxyConverter(); this.alarmCommandHandler = alarmCommandHandler; this.pumpStep = pumpStep ?? (static () => { }); + this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout; } /// @@ -457,6 +477,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor return CreateInvalidRequestReply(command, "WriteSecured command value is required."); } + // Baseline BEFORE the COM call: a completion that dispatches during or + // immediately after WriteSecured bumps the version past this snapshot, + // so a fast commit still correlates (no missed-callback window). + MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache; + ulong completionBaseline = completionCache.CurrentVersion( + writeSecuredCommand.ServerHandle, + writeSecuredCommand.ItemHandle); + session.WriteSecured( writeSecuredCommand.ServerHandle, writeSecuredCommand.ItemHandle, @@ -464,7 +492,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor writeSecuredCommand.VerifierUserId, variantConverter.ConvertToComValue(writeSecuredCommand.Value)); - return CreateOkReply(command); + MxCommandReply reply = CreateOkReply(command); + AwaitWriteCompletion( + reply, + completionCache, + writeSecuredCommand.ServerHandle, + writeSecuredCommand.ItemHandle, + completionBaseline); + return reply; } private MxCommandReply ExecuteWriteSecured2(StaCommand command) @@ -485,6 +520,12 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required."); } + // Same pre-call baseline rule as ExecuteWriteSecured. + MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache; + ulong completionBaseline = completionCache.CurrentVersion( + writeSecured2Command.ServerHandle, + writeSecured2Command.ItemHandle); + session.WriteSecured2( writeSecured2Command.ServerHandle, writeSecured2Command.ItemHandle, @@ -493,7 +534,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor variantConverter.ConvertToComValue(writeSecured2Command.Value), variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue)); - return CreateOkReply(command); + MxCommandReply reply = CreateOkReply(command); + AwaitWriteCompletion( + reply, + completionCache, + writeSecured2Command.ServerHandle, + writeSecured2Command.ItemHandle, + completionBaseline); + return reply; } private MxCommandReply ExecuteAddItemBulk(StaCommand command) @@ -897,6 +945,39 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor } } + /// + /// Bounded pump-wait for the OnWriteComplete row matching a + /// WriteSecured/WriteSecured2 call, copied onto the reply when it + /// arrives in time. The executor holds the STA thread but pumps + /// Windows messages each poll (ReadBulk precedent) so the COM callback + /// can dispatch re-entrantly; on expiry the reply keeps its empty + /// statuses — the consumer's unconfirmed path, never a synthesized + /// failure. Protocol status/hresult stay acceptance-only either way. + /// + private void AwaitWriteCompletion( + MxCommandReply reply, + MxAccessWriteCompletionCache completionCache, + int serverHandle, + int itemHandle, + ulong completionBaseline) + { + if (writeCompletionTimeout <= TimeSpan.Zero) + { + return; + } + + if (completionCache.TryWaitForCompletion( + serverHandle, + itemHandle, + completionBaseline, + DateTime.UtcNow + writeCompletionTimeout, + pumpStep, + out Google.Protobuf.Collections.RepeatedField statuses)) + { + reply.Statuses.Add(statuses); + } + } + private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception) { return new MxCommandReply diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs index a82e0a6..78f1c0d 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessSession.cs @@ -13,6 +13,7 @@ public sealed class MxAccessSession : IDisposable private readonly IMxAccessEventSink eventSink; private readonly MxAccessHandleRegistry handleRegistry; private readonly MxAccessValueCache valueCache; + private readonly MxAccessWriteCompletionCache writeCompletionCache; private bool disposed; private MxAccessSession( @@ -21,6 +22,7 @@ public sealed class MxAccessSession : IDisposable IMxAccessEventSink eventSink, MxAccessHandleRegistry handleRegistry, MxAccessValueCache valueCache, + MxAccessWriteCompletionCache writeCompletionCache, int creationThreadId) { this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject)); @@ -28,6 +30,7 @@ public sealed class MxAccessSession : IDisposable this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink)); this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry)); this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache)); + this.writeCompletionCache = writeCompletionCache ?? throw new ArgumentNullException(nameof(writeCompletionCache)); CreationThreadId = creationThreadId; } @@ -45,6 +48,14 @@ public sealed class MxAccessSession : IDisposable /// public MxAccessValueCache ValueCache => valueCache; + /// + /// Per-session OnWriteComplete completion cache populated by the event + /// sink. The write command executor consults it after a + /// WriteSecured/WriteSecured2 COM call so the unary reply can carry + /// the correlated completion outcome. + /// + public MxAccessWriteCompletionCache WriteCompletionCache => writeCompletionCache; + /// Creates a WorkerReady message with session metadata. /// Process ID of the worker. /// The populated message. @@ -105,6 +116,9 @@ public sealed class MxAccessSession : IDisposable eventSink, handleRegistry ?? new MxAccessHandleRegistry(), valueCache ?? new MxAccessValueCache(), + eventSink is IWriteCompletionCacheProvider provider + ? provider.WriteCompletionCache + : new MxAccessWriteCompletionCache(), creationThreadId ?? Environment.CurrentManagedThreadId); } @@ -149,12 +163,22 @@ public sealed class MxAccessSession : IDisposable ? baseSink.ValueCache : new MxAccessValueCache(); + // Share the sink's completion cache the same way (the production + // sink and completion-aware test sinks implement the provider + // seam); fall back to a fresh cache for other fakes — the write + // executor then simply never observes a completion and replies + // unconfirmed. + MxAccessWriteCompletionCache writeCompletionCache = eventSink is IWriteCompletionCacheProvider provider + ? provider.WriteCompletionCache + : new MxAccessWriteCompletionCache(); + return new MxAccessSession( mxAccessComObject, new MxAccessComServer(mxAccessComObject), eventSink, new MxAccessHandleRegistry(), valueCache, + writeCompletionCache, Environment.CurrentManagedThreadId); } catch (Exception exception) diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs index 51ccbab..90e622f 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs @@ -11,6 +11,14 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; public sealed class MxAccessStaSession : IWorkerRuntimeSession { + /// + /// Environment variable the gateway's WorkerProcessLauncher sets from + /// MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the + /// write-completion wait (pure fire-and-forget replies). + /// + internal const string WriteCompletionWaitEnvironmentVariableName = + "MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS"; + private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500); private readonly IMxAccessComObjectFactory factory; @@ -157,6 +165,32 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession /// public MxAccessEventQueue EventQueue => eventQueue; + /// + /// Bounded WriteSecured/WriteSecured2 completion wait handed to the + /// command executor at . + /// Internal-settable as a test seam so Worker.Tests can shorten it + /// without env-var plumbing. + /// + internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout(); + + /// + /// Resolves the write-completion wait from the launcher-provided + /// environment variable; a missing or invalid value falls back to + /// . + /// + internal static TimeSpan ResolveWriteCompletionTimeout() + { + string? value = Environment.GetEnvironmentVariable(WriteCompletionWaitEnvironmentVariableName); + return int.TryParse( + value, + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out int milliseconds) + && milliseconds >= 0 + ? TimeSpan.FromMilliseconds(milliseconds) + : MxAccessCommandExecutor.DefaultWriteCompletionTimeout; + } + /// /// Starts the MXAccess COM session asynchronously. /// @@ -208,12 +242,14 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession session, new VariantConverter(), 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())); + // ReadBulk and the write-completion wait need to pump + // Windows messages while they wait for the inbound COM + // callback (OnDataChange / OnWriteComplete) so it 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(), + writeCompletionTimeout: WriteCompletionTimeout)); return session.CreateWorkerReady(workerProcessId); }, diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs new file mode 100644 index 0000000..1bb5e35 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Google.Protobuf.Collections; +using ZB.MOM.WW.MxGateway.Contracts.Proto; + +namespace ZB.MOM.WW.MxGateway.Worker.MxAccess; + +/// +/// Per-session cache of the most recent OnWriteComplete status rows +/// for each (server handle, item handle) pair. Written by the MXAccess +/// event sink as completion callbacks arrive; read by the write command +/// executor so a WriteSecured/WriteSecured2 reply can carry the correlated +/// MXAccess outcome instead of proving command acceptance only. +/// +/// +/// Same threading posture as : 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. A single sync root keeps it nominally thread-safe +/// for tests that drive it from a non-STA thread. +/// +public sealed class MxAccessWriteCompletionCache +{ + private readonly Dictionary entries = new(); + private readonly object syncRoot = new(); + + /// Records the status rows of a fresh OnWriteComplete callback for the given handle pair. + /// MXAccess server handle. + /// MXAccess item handle. + /// Status rows from the mapped OnWriteComplete event; cloned before storing. + public void Record( + int serverHandle, + int itemHandle, + RepeatedField statuses) + { + if (statuses is null) + { + throw new ArgumentNullException(nameof(statuses)); + } + + lock (syncRoot) + { + long key = CreateItemKey(serverHandle, itemHandle); + ulong version = entries.TryGetValue(key, out CompletionEntry existing) + ? existing.Version + 1 + : 1UL; + entries[key] = new CompletionEntry(version, statuses.Clone()); + } + } + + /// Returns the current completion version for a handle pair, or 0 if none was recorded. + /// MXAccess server handle. + /// MXAccess item handle. + /// The current completion version, or 0 if no completion was recorded. + public ulong CurrentVersion( + int serverHandle, + int itemHandle) + { + lock (syncRoot) + { + return entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry existing) + ? existing.Version + : 0UL; + } + } + + /// + /// Polls for a completion newer than until it + /// arrives or the deadline elapses, calling on every + /// poll iteration so the worker's STA can dispatch the inbound MXAccess + /// OnWriteComplete message. Same loop shape as + /// . + /// + /// MXAccess server handle. + /// MXAccess item handle. + /// Version snapshot captured before the write COM call. + /// Absolute UTC deadline. + /// Action that pumps any pending Windows messages. + /// The recorded status rows if a completion arrived before the deadline; empty otherwise. + /// How long to sleep between pump cycles. Default 5 ms. + /// if a completion newer than arrived before the deadline; otherwise . + public bool TryWaitForCompletion( + int serverHandle, + int itemHandle, + ulong sinceVersion, + DateTime deadlineUtc, + Action pumpStep, + out RepeatedField statuses, + int pollIntervalMs = 5) + { + if (pumpStep is null) + { + throw new ArgumentNullException(nameof(pumpStep)); + } + + while (true) + { + pumpStep(); + + lock (syncRoot) + { + if (entries.TryGetValue(CreateItemKey(serverHandle, itemHandle), out CompletionEntry entry) + && entry.Version > sinceVersion) + { + statuses = entry.Statuses; + return true; + } + } + + if (DateTime.UtcNow >= deadlineUtc) + { + statuses = new RepeatedField(); + return false; + } + + Thread.Sleep(pollIntervalMs); + } + } + + private static long CreateItemKey( + int serverHandle, + int itemHandle) + { + return ((long)serverHandle << 32) | (uint)itemHandle; + } + + /// + /// Snapshot of the most recent OnWriteComplete status rows for a handle + /// pair. increments by one on every + /// call so the write executor can detect "a new + /// completion arrived since I captured my baseline". + /// + /// + /// Plain readonly struct (not a record) so this compiles under the + /// worker's net48 target, which lacks IsExternalInit. + /// + private readonly struct CompletionEntry + { + public CompletionEntry( + ulong version, + RepeatedField statuses) + { + Version = version; + Statuses = statuses; + } + + public ulong Version { get; } + + public RepeatedField Statuses { get; } + } +}