feat(worker): bounded pump-wait correlates OnWriteComplete onto secured-write replies

This commit is contained in:
Joseph Doherty
2026-08-09 12:24:23 -04:00
parent 8de23086d0
commit 66fe063410
2 changed files with 126 additions and 9 deletions
@@ -14,11 +14,22 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
/// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary> /// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary>
internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000); internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000);
/// <summary>
/// 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.
/// </summary>
internal static readonly TimeSpan DefaultWriteCompletionTimeout = TimeSpan.FromMilliseconds(1500);
private readonly MxAccessSession session; private readonly MxAccessSession session;
private readonly VariantConverter variantConverter; private readonly VariantConverter variantConverter;
private readonly MxStatusProxyConverter statusProxyConverter; private readonly MxStatusProxyConverter statusProxyConverter;
private readonly IAlarmCommandHandler? alarmCommandHandler; private readonly IAlarmCommandHandler? alarmCommandHandler;
private readonly Action pumpStep; private readonly Action pumpStep;
private readonly TimeSpan writeCompletionTimeout;
/// <summary> /// <summary>
/// Initializes a command executor with an MXAccess session. /// Initializes a command executor with an MXAccess session.
@@ -71,17 +82,26 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param> /// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
/// <param name="alarmCommandHandler">Optional handler for alarm-side commands.</param> /// <param name="alarmCommandHandler">Optional handler for alarm-side commands.</param>
/// <param name="pumpStep">Action to pump Windows messages, or null for tests.</param> /// <param name="pumpStep">Action to pump Windows messages, or null for tests.</param>
/// <param name="writeCompletionTimeout">
/// Bounded wait for the OnWriteComplete callback after a
/// WriteSecured/WriteSecured2 COM call, or null for
/// <see cref="DefaultWriteCompletionTimeout"/>. Zero (or negative)
/// disables the wait entirely — replies keep the pure fire-and-forget
/// shape.
/// </param>
public MxAccessCommandExecutor( public MxAccessCommandExecutor(
MxAccessSession session, MxAccessSession session,
VariantConverter variantConverter, VariantConverter variantConverter,
IAlarmCommandHandler? alarmCommandHandler, IAlarmCommandHandler? alarmCommandHandler,
Action? pumpStep) Action? pumpStep,
TimeSpan? writeCompletionTimeout = null)
{ {
this.session = session ?? throw new ArgumentNullException(nameof(session)); this.session = session ?? throw new ArgumentNullException(nameof(session));
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter)); this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.statusProxyConverter = new MxStatusProxyConverter(); this.statusProxyConverter = new MxStatusProxyConverter();
this.alarmCommandHandler = alarmCommandHandler; this.alarmCommandHandler = alarmCommandHandler;
this.pumpStep = pumpStep ?? (static () => { }); this.pumpStep = pumpStep ?? (static () => { });
this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -457,6 +477,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "WriteSecured command value is required."); 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( session.WriteSecured(
writeSecuredCommand.ServerHandle, writeSecuredCommand.ServerHandle,
writeSecuredCommand.ItemHandle, writeSecuredCommand.ItemHandle,
@@ -464,7 +492,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
writeSecuredCommand.VerifierUserId, writeSecuredCommand.VerifierUserId,
variantConverter.ConvertToComValue(writeSecuredCommand.Value)); 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) private MxCommandReply ExecuteWriteSecured2(StaCommand command)
@@ -485,6 +520,12 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required."); 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( session.WriteSecured2(
writeSecured2Command.ServerHandle, writeSecured2Command.ServerHandle,
writeSecured2Command.ItemHandle, writeSecured2Command.ItemHandle,
@@ -493,7 +534,14 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
variantConverter.ConvertToComValue(writeSecured2Command.Value), variantConverter.ConvertToComValue(writeSecured2Command.Value),
variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue)); 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) private MxCommandReply ExecuteAddItemBulk(StaCommand command)
@@ -897,6 +945,39 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
} }
} }
/// <summary>
/// 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.
/// </summary>
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<MxStatusProxy> statuses))
{
reply.Statuses.Add(statuses);
}
}
private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception) private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception)
{ {
return new MxCommandReply return new MxCommandReply
@@ -11,6 +11,14 @@ namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
public sealed class MxAccessStaSession : IWorkerRuntimeSession public sealed class MxAccessStaSession : IWorkerRuntimeSession
{ {
/// <summary>
/// Environment variable the gateway's WorkerProcessLauncher sets from
/// MxGateway:Worker:WriteCompletionWaitMilliseconds. 0 disables the
/// write-completion wait (pure fire-and-forget replies).
/// </summary>
internal const string WriteCompletionWaitEnvironmentVariableName =
"MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS";
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500); private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
private readonly IMxAccessComObjectFactory factory; private readonly IMxAccessComObjectFactory factory;
@@ -157,6 +165,32 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
/// </summary> /// </summary>
public MxAccessEventQueue EventQueue => eventQueue; public MxAccessEventQueue EventQueue => eventQueue;
/// <summary>
/// Bounded WriteSecured/WriteSecured2 completion wait handed to the
/// command executor at <see cref="StartAsync(string, int, CancellationToken)"/>.
/// Internal-settable as a test seam so Worker.Tests can shorten it
/// without env-var plumbing.
/// </summary>
internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();
/// <summary>
/// Resolves the write-completion wait from the launcher-provided
/// environment variable; a missing or invalid value falls back to
/// <see cref="MxAccessCommandExecutor.DefaultWriteCompletionTimeout"/>.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Starts the MXAccess COM session asynchronously. /// Starts the MXAccess COM session asynchronously.
/// </summary> /// </summary>
@@ -208,12 +242,14 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
session, session,
new VariantConverter(), new VariantConverter(),
alarmCommandHandler, alarmCommandHandler,
// ReadBulk needs to pump Windows messages while it waits // ReadBulk and the write-completion wait need to pump
// for the first OnDataChange callback so the inbound COM // Windows messages while they wait for the inbound COM
// event can dispatch on this same STA thread. The pump // callback (OnDataChange / OnWriteComplete) so it can
// step closes over staRuntime so it always pumps the // dispatch on this same STA thread. The pump step
// pump tied to the apartment that owns this session. // closes over staRuntime so it always pumps the pump
pumpStep: () => staRuntime.PumpPendingMessages())); // tied to the apartment that owns this session.
pumpStep: () => staRuntime.PumpPendingMessages(),
writeCompletionTimeout: WriteCompletionTimeout));
return session.CreateWorkerReady(workerProcessId); return session.CreateWorkerReady(workerProcessId);
}, },