diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md index 28f49a1..fab6491 100644 --- a/docs/MxAccessWorkerInstanceDesign.md +++ b/docs/MxAccessWorkerInstanceDesign.md @@ -317,6 +317,30 @@ whose write workload is effectively fire-and-forget — no consumer reads the reply's `statuses` — can lower it, or set `0` to skip the wait entirely and reply on acceptance alone. +**Write-completion correlation is recency-based, not per-call.** MXAccess's +`OnWriteComplete` callback carries no correlation token — only the server/item +handle pair — so `MxAccessCommandExecutor` correlates a completion to a write +by *version*: it snapshots `MxAccessWriteCompletionCache.CurrentVersion` for +the handle as a baseline before the COM call, then reports the first +completion whose version exceeds that baseline as the write's outcome +(`AwaitWriteCompletion`). This is exact for the common case, but if an earlier +write to the same handle times out unconfirmed — its `OnWriteComplete` still +undelivered when the wait gives up — and the caller immediately issues another +write to the same tag, the stale callback can dispatch during the second +write's pump-wait and be reported as *its* outcome. The executor mitigates the +most common instance of this (`CaptureCompletionBaseline`) by running one +`pumpStep()` immediately before capturing the baseline, so a completion already +queued for the handle folds into the baseline instead of being available to +ride the next write's reply; this closes the gap for a callback that arrived +before the new write started, but a callback that MXAccess queues after that +pump step and before the COM call returns (or during the wait itself) is still +indistinguishable from the new write's own completion. Full closure is not +possible without an MXAccess-side correlation token, which does not exist — +this is a property of the COM surface, not a gap this worker introduced (a +native client correlating by handle has the identical ambiguity). The exposure +is bounded: `statuses` is an enrichment field, and `Hresult`/`ProtocolStatus` +stay acceptance-only regardless of which completion (if any) was attributed. + ## COM Creation The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs index 62b4e94..46cbc7e 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs @@ -445,7 +445,8 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor // also fire-and-forget in MXAccess, and its commit outcome only exists // in the later OnWriteComplete callback. MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache; - ulong completionBaseline = completionCache.CurrentVersion( + ulong completionBaseline = CaptureCompletionBaseline( + completionCache, writeCommand.ServerHandle, writeCommand.ItemHandle); @@ -485,7 +486,8 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor // Same pre-call baseline rule as ExecuteWriteSecured. MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache; - ulong completionBaseline = completionCache.CurrentVersion( + ulong completionBaseline = CaptureCompletionBaseline( + completionCache, write2Command.ServerHandle, write2Command.ItemHandle); @@ -523,7 +525,8 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor // 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( + ulong completionBaseline = CaptureCompletionBaseline( + completionCache, writeSecuredCommand.ServerHandle, writeSecuredCommand.ItemHandle); @@ -564,7 +567,8 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor // Same pre-call baseline rule as ExecuteWriteSecured. MxAccessWriteCompletionCache completionCache = session.WriteCompletionCache; - ulong completionBaseline = completionCache.CurrentVersion( + ulong completionBaseline = CaptureCompletionBaseline( + completionCache, writeSecured2Command.ServerHandle, writeSecured2Command.ItemHandle); @@ -994,7 +998,31 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor } /// - /// Bounded pump-wait for the OnWriteComplete row matching a + /// Captures the write-completion baseline for / + /// immediately before a Write/Write2/WriteSecured/ + /// WriteSecured2 COM call. Pumps once first (WRK-32) so an OnWriteComplete + /// already queued for this handle — most commonly the still-undelivered callback of an + /// earlier write to the same tag that this worker gave up waiting on — folds into the + /// baseline instead of being available to ride this write's reply. + /// + /// + /// This narrows, but does not close, the correlation gap documented on + /// : a completion that MXAccess queues after this pump + /// step but before the COM call returns is still indistinguishable from this write's own + /// completion, because OnWriteComplete carries no per-call token — only the + /// server/item handle pair. + /// + private ulong CaptureCompletionBaseline( + MxAccessWriteCompletionCache completionCache, + int serverHandle, + int itemHandle) + { + pumpStep(); + return completionCache.CurrentVersion(serverHandle, itemHandle); + } + + /// + /// Bounded pump-wait for the OnWriteComplete row matching a Write/Write2/ /// 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 @@ -1002,6 +1030,21 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor /// statuses — the consumer's unconfirmed path, never a synthesized /// failure. Protocol status/hresult stay acceptance-only either way. /// + /// + /// Correlation is recency-based per handle, not per call: MXAccess's + /// OnWriteComplete callback carries no correlation token, only the server/item + /// handle pair, so this method reports the first completion whose version exceeds + /// as this write's outcome. If an earlier write to + /// the same handle times out unconfirmed and its callback is still undelivered, that + /// stale completion can dispatch during this write's pump-wait and ride this reply + /// instead — 's pre-call pump only folds in a + /// completion already queued before this write started; it cannot fold in one queued + /// during the COM call or the wait itself. The Hresult/ProtocolStatus + /// fields stay acceptance-only regardless, so the exposure is limited to the enrichment + /// statuses field, and it is inherent to the COM surface — a native client + /// correlating by handle has the identical ambiguity. Full closure needs an + /// MXAccess-side correlation token, which does not exist. + /// private void AwaitWriteCompletion( MxCommandReply reply, MxAccessWriteCompletionCache completionCache,