perf(worker): message-driven completion waits — the STA pumps continuously while waiting

This commit is contained in:
Joseph Doherty
2026-08-15 16:58:54 -04:00
parent afec56d03b
commit dc9424d3bd
6 changed files with 396 additions and 19 deletions
@@ -4,6 +4,7 @@ using System.Threading;
using Google.Protobuf.Collections;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -26,6 +27,18 @@ public sealed class MxAccessValueCache
private readonly Dictionary<long, CachedValue> entries = new();
private readonly object syncRoot = new();
// Set by Set() so a waiter blocked in TryWaitForUpdate wakes the moment a
// value lands instead of after the next blind poll tick. In the live worker
// the value is recorded from inside the waiter's own pumpStep (COM
// dispatches OnDataChange on the STA), so the message wake is what does the
// work there; this handle covers the cross-thread producers — the tests, and
// any future off-STA writer. Not IDisposable on purpose: the cache is
// co-owned by MxAccessBaseEventSink and MxAccessSession with no single
// teardown owner, and a late COM callback must never Set a disposed handle.
// One kernel event per session (one session per worker process), released by
// the handle's finalizer.
private readonly AutoResetEvent updateSignal = new(false);
/// <summary>Records a fresh OnDataChange payload for the given handle pair.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
@@ -68,6 +81,11 @@ public sealed class MxAccessValueCache
cachedTimestamp,
mxEvent.Statuses.Clone());
}
// Signaled outside the lock: the waiter re-takes syncRoot (through
// TryGet) the instant it wakes, so signaling while holding it would hand
// it guaranteed contention.
updateSignal.Set();
}
/// <summary>Tries to read the most recent cached value for the handle pair.</summary>
@@ -107,16 +125,28 @@ public sealed class MxAccessValueCache
/// <summary>
/// Waits until the cache entry's version exceeds <paramref name="sinceVersion"/>
/// or the deadline elapses, calling <paramref name="pumpStep"/> on every poll
/// or the deadline elapses, calling <paramref name="pumpStep"/> on every
/// iteration so the worker's STA can dispatch the inbound MXAccess message.
/// </summary>
/// <remarks>
/// The idle part of each iteration blocks in
/// <see cref="StaWaitHelper.WaitForSignalOrMessages"/> rather than
/// <c>Thread.Sleep</c>: a sleeping STA pumps no Windows messages, so a
/// ReadBulk waiting on the first OnDataChange would only dispatch it at
/// poll-tick granularity — and it holds the apartment for up to
/// <c>timeout_ms</c> per tag, stalling every other COM event with it. The
/// Win32 wait returns the instant a message needs pumping (or the value
/// is recorded from another thread), so the apartment keeps dispatching
/// continuously for the whole wait. The per-tag timeout, the deadline
/// math, and both return shapes are unchanged.
/// </remarks>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="sinceVersion">Version snapshot captured before the wait.</param>
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
/// <param name="value">The cached value if the update was received before the deadline.</param>
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
/// <param name="pollIntervalMs">Upper bound on one idle wait slice — the cadence at which <paramref name="pumpStep"/> runs when nothing wakes the wait. Default 5 ms, itself capped at <see cref="StaWaitHelper.MaxFallbackTickMilliseconds"/>.</param>
/// <returns><see langword="true"/> if an update newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
public bool TryWaitForUpdate(
int serverHandle,
@@ -134,6 +164,12 @@ public sealed class MxAccessValueCache
while (true)
{
// Pumped unconditionally at the top of every iteration — including
// the one that immediately follows a signaled wait, and the very
// first before any waiting. Dispatching the message is what fills
// the cache in the live worker, and unit tests stand in for the STA
// by driving the cache from their fake pumpStep, so this call must
// never be conditional on a real message queue.
pumpStep();
if (TryGet(serverHandle, itemHandle, out value) && value.Version > sinceVersion)
@@ -141,12 +177,18 @@ public sealed class MxAccessValueCache
return true;
}
if (DateTime.UtcNow >= deadlineUtc)
// Same expiry test as the sleep-polled version: remaining <= zero is
// exactly DateTime.UtcNow >= deadlineUtc. `value` keeps whatever the
// TryGet above left in it on this path, as before.
TimeSpan remaining = deadlineUtc - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
return false;
}
Thread.Sleep(pollIntervalMs);
StaWaitHelper.WaitForSignalOrMessages(
updateSignal,
StaWaitHelper.ClampWaitMilliseconds(remaining, pollIntervalMs));
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -25,6 +26,18 @@ public sealed class MxAccessWriteCompletionCache
private readonly Dictionary<long, CompletionEntry> entries = new();
private readonly object syncRoot = new();
// Set by Record so a waiter blocked in TryWaitForCompletion wakes the moment
// a completion lands instead of after the next blind poll tick. In the live
// worker the completion is recorded from inside the waiter's own pumpStep
// (COM dispatches OnWriteComplete on the STA), so the message wake is what
// does the work there; this handle covers the cross-thread producers — the
// tests, and any future off-STA recorder. Not IDisposable on purpose: the
// cache is co-owned by MxAccessBaseEventSink and MxAccessSession with no
// single teardown owner, and a late COM callback must never Set a disposed
// handle. One kernel event per session (one session per worker process),
// released by the handle's finalizer.
private readonly AutoResetEvent completionSignal = new(false);
/// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
@@ -47,6 +60,10 @@ public sealed class MxAccessWriteCompletionCache
: 1UL;
entries[key] = new CompletionEntry(version, statuses.Clone());
}
// Signaled outside the lock: the waiter re-takes syncRoot the instant it
// wakes, so signaling while holding it would hand it guaranteed contention.
completionSignal.Set();
}
/// <summary>Returns the current completion version for a handle pair, or 0 if none was recorded.</summary>
@@ -66,19 +83,29 @@ public sealed class MxAccessWriteCompletionCache
}
/// <summary>
/// Polls for a completion newer than <paramref name="sinceVersion"/> until it
/// Waits for a completion newer than <paramref name="sinceVersion"/> until it
/// arrives or the deadline elapses, calling <paramref name="pumpStep"/> on every
/// poll iteration so the worker's STA can dispatch the inbound MXAccess
/// iteration so the worker's STA can dispatch the inbound MXAccess
/// OnWriteComplete message. Same loop shape as
/// <see cref="MxAccessValueCache.TryWaitForUpdate"/>.
/// </summary>
/// <remarks>
/// The idle part of each iteration blocks in
/// <see cref="StaWaitHelper.WaitForSignalOrMessages"/> rather than
/// <c>Thread.Sleep</c>: a sleeping STA pumps no Windows messages, so the
/// inbound OnWriteComplete could only ever be dispatched at poll-tick
/// granularity. The Win32 wait returns the instant a message needs
/// pumping (or the completion is recorded from another thread), so the
/// apartment keeps dispatching COM events continuously for the whole
/// wait. Timeouts, deadline math, and both return shapes are unchanged.
/// </remarks>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
/// <param name="sinceVersion">Version snapshot captured before the write COM call.</param>
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
/// <param name="statuses">The recorded status rows if a completion arrived before the deadline; empty otherwise.</param>
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
/// <param name="pollIntervalMs">Upper bound on one idle wait slice — the cadence at which <paramref name="pumpStep"/> runs when nothing wakes the wait. Default 5 ms, itself capped at <see cref="StaWaitHelper.MaxFallbackTickMilliseconds"/>.</param>
/// <returns><see langword="true"/> if a completion newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
public bool TryWaitForCompletion(
int serverHandle,
@@ -96,6 +123,12 @@ public sealed class MxAccessWriteCompletionCache
while (true)
{
// Pumped unconditionally at the top of every iteration — including
// the one that immediately follows a signaled wait, and the very
// first before any waiting. Dispatching the message is what fills
// the cache in the live worker, and unit tests stand in for the STA
// by recording the completion from inside their fake pumpStep, so
// this call must never be conditional on a real message queue.
pumpStep();
lock (syncRoot)
@@ -108,13 +141,18 @@ public sealed class MxAccessWriteCompletionCache
}
}
if (DateTime.UtcNow >= deadlineUtc)
// Same expiry test as the sleep-polled version: remaining <= zero is
// exactly DateTime.UtcNow >= deadlineUtc.
TimeSpan remaining = deadlineUtc - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
statuses = new RepeatedField<MxStatusProxy>();
return false;
}
Thread.Sleep(pollIntervalMs);
StaWaitHelper.WaitForSignalOrMessages(
completionSignal,
StaWaitHelper.ClampWaitMilliseconds(remaining, pollIntervalMs));
}
}