97dc715dac
v2-ci / unit-tests (push) Has been cancelled
Three write bounds were configured independently: the inbound-write gateway's 10 s Ask, DriverHostActor's fixed 8 s Ask of the child, and the child's own write timeout (5 s + up to 2 s grace) with a fixed 8 s queue budget. An outer Ask that fires first reports the write failed and the node reverts while the child is still driving the device, so any write timeout above ~6 s (or a write dequeued late in its 8 s budget) let the device take a value the client was told failed. DriverWriteBounds (Runtime) derives them all from DriverWrites:Timeout: token cancel = Timeout abandon = Timeout + min(Timeout/2, 2 s) (RunBound) queue budget = RunBound child replies <= queue budget + RunBound (ReplyBound) host Ask = ReplyBound + 1 s gateway Ask = host Ask + 1 s Default Timeout is 10 s (the Tier C Write timeout, so no tier default is capped below its own policy); host Ask 25 s, gateway Ask 26 s. Tier A/B writes are still cancelled first by their 2 s / 4 s tier timeouts. - DriverHostActor takes writeBounds + writeClock, spawns children with them, and runs its Ask deadline as a token on the write clock (Infinite Akka timeout), so host and child bounds share one clock. - WriteAttribute gains IssuedAt (write-clock timestamp, stamped by the host): the queue budget counts mailbox time too, and a write past it is failed BadTimeout and never started - including on arrival. Stale queued writes are expired across the whole queue, not only up to a free slot. - ActorNodeWriteGateway's default Ask = Default.GatewayAsk; the hosted service passes the configured one. - Startup validation (Host DriverWriteBoundsBinding, same warn-and-clamp style as ScriptingOptionsBinding): unparsable / non-positive -> default, above 5 min -> clamped, and each tier whose default Write timeout exceeds the write timeout is named in a Warning (its policy would never take effect). The derived bounds are logged at Information. Per-instance ResilienceConfig Write overrides arrive with a deployment, so DriverCapabilityInvokerFactory warns at spawn (writeTimeoutCeiling). - appsettings.json documents DriverWrites:Timeout. Tests (manual clock): - DriverHostActorWriteAskTests: a held write under a 9 s timeout survives an 8.5 s step and succeeds; a token-ignoring write is answered by the child's BadTimeout at 9 s + grace, not by the Ask; the Infinite-timeout Ask ends exactly at its clock deadline. - DriverInstanceActorConcurrentWriteTests: a write issued longer ago than the queue budget is failed and never reaches the driver; positive control at exactly the budget is written. - DriverWriteBoundsTests: every outer bound covers the inner ones for 0.5 s to 300 s timeouts. - DriverWriteBoundsBindingTests: default fits every tier; 3 s timeout is caught at startup with Tier B and C named (not A); bad values fall back with a warning; over-max clamps. - DriverCapabilityInvokerFactoryTests: spawn warns when a Write override exceeds the ceiling; no warning within it or without a ceiling. Mutation checks: a fixed 8 s host Ask on the same clock fails both 9 s host tests; stamping arrival time instead of IssuedAt fails the issued-at test; dropping the tier check / factory check fails their tests. Runtime.Tests 961 passed / 3 skipped at AKKA_TEST_TIMEFACTOR 3 and 1; Host.Tests 232/232 at 3 and 1; Core.Tests 361 passed / 1 skipped. Solution Release build: 0 warnings. ContinuousHistorizationRecorderTypedValueTests (historian) deliberately left for the historian lane. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
106 lines
6.0 KiB
C#
106 lines
6.0 KiB
C#
namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
|
|
/// <summary>
|
|
/// Every bound on an operator write's path, derived from ONE configured value: the write timeout
|
|
/// (<c>DriverWrites:Timeout</c>). lmxopcua#777.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A write passes three bounds on its way to the device, and they used to be configured independently:
|
|
/// the gateway's Ask of <see cref="DriverHostActor"/> (10 s), the host's Ask of the
|
|
/// <see cref="DriverInstanceActor"/> child (a fixed 8 s), and the child's own write timeout plus the
|
|
/// queued-write budget (5 s + up to 2 s, and 8 s in the queue). An outer Ask that fires first reports the
|
|
/// write failed and the node manager reverts the node, while the child is still driving the device and the
|
|
/// device may still take the value — the client is told "failed" for a write that applied. Raising the
|
|
/// write timeout above ~6 s silently produced exactly that.
|
|
/// </para>
|
|
/// <para>
|
|
/// Here every bound is derived from <see cref="Timeout"/>, innermost first, so each outer bound covers
|
|
/// the worst case of everything inside it:
|
|
/// <list type="number">
|
|
/// <item><see cref="Timeout"/> — the write's cancellation token fires.</item>
|
|
/// <item><see cref="RunBound"/> = timeout + <see cref="AbandonGrace"/> — a write that ignored its
|
|
/// token is abandoned (answers <c>BadTimeout</c>).</item>
|
|
/// <item><see cref="QueueBudget"/> = <see cref="RunBound"/> — the longest a write may wait for a
|
|
/// slot (or for an earlier write to the same tag) before it is failed instead of started.</item>
|
|
/// <item><see cref="ReplyBound"/> = queue + run — the child answers every write within this of its
|
|
/// arrival.</item>
|
|
/// <item><see cref="HostAsk"/> = reply + <see cref="ReplyMargin"/>, and
|
|
/// <see cref="GatewayAsk"/> = host Ask + margin.</item>
|
|
/// </list>
|
|
/// The Asks are safety nets for an actor that never replies, not a second write timeout.
|
|
/// </para>
|
|
/// <para>
|
|
/// The Phase 6.1 resilience tier's per-capability Write timeout (<c>DriverResilienceOptions</c>) runs
|
|
/// INSIDE <see cref="Timeout"/>: the actor's token is the outer cap, so a tier Write timeout above
|
|
/// <see cref="Timeout"/> never takes effect. The Host warns about that at startup (tier defaults) and at
|
|
/// each driver spawn (per-instance <c>ResilienceConfig</c> overrides).
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed record DriverWriteBounds
|
|
{
|
|
/// <summary>The configuration section the write timeout is bound from.</summary>
|
|
public const string SectionName = "DriverWrites";
|
|
|
|
/// <summary>
|
|
/// Default write timeout. 10 s — the longest resilience-tier Write timeout (Tier C), so no tier default
|
|
/// is capped below its own policy. Tier A/B writes are cancelled by their tighter tier timeout first.
|
|
/// </summary>
|
|
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
|
|
|
/// <summary>Largest accepted write timeout; a larger configured value is clamped (with a startup warning).</summary>
|
|
public static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(5);
|
|
|
|
/// <summary>
|
|
/// Headroom each Ask keeps over the bound it wraps, for mailbox and dispatch latency between the reply being
|
|
/// sent and the Ask observing it.
|
|
/// </summary>
|
|
public static readonly TimeSpan ReplyMargin = TimeSpan.FromSeconds(1);
|
|
|
|
/// <summary>The write bounds derived from <see cref="DefaultTimeout"/>.</summary>
|
|
public static DriverWriteBounds Default { get; } = new(DefaultTimeout);
|
|
|
|
/// <summary>Creates the bounds for a write timeout.</summary>
|
|
/// <param name="timeout">The write timeout; must be positive.</param>
|
|
/// <exception cref="ArgumentOutOfRangeException">The timeout is zero or negative.</exception>
|
|
public DriverWriteBounds(TimeSpan timeout)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(timeout, TimeSpan.Zero);
|
|
Timeout = timeout;
|
|
}
|
|
|
|
/// <summary>When a write's cancellation token is cancelled (the write answers <c>"write timeout"</c>).</summary>
|
|
public TimeSpan Timeout { get; }
|
|
|
|
/// <summary>
|
|
/// How long after <see cref="Timeout"/> a write that has not returned is abandoned as token-ignoring: half
|
|
/// the timeout, at most 2 s. Long enough for a driver that honours its token to answer the cancellation
|
|
/// itself.
|
|
/// </summary>
|
|
public TimeSpan AbandonGrace => AbandonGraceFor(Timeout);
|
|
|
|
/// <summary>The latest a running write is answered after it starts: <see cref="Timeout"/> + <see cref="AbandonGrace"/>.</summary>
|
|
public TimeSpan RunBound => Timeout + AbandonGrace;
|
|
|
|
/// <summary>
|
|
/// The longest a write may wait for a slot before it is failed <c>BadTimeout</c> instead of being written:
|
|
/// one <see cref="RunBound"/>, i.e. long enough for an earlier write to the same tag to finish.
|
|
/// </summary>
|
|
public TimeSpan QueueBudget => RunBound;
|
|
|
|
/// <summary>The latest the driver child answers a write after it arrives: <see cref="QueueBudget"/> + <see cref="RunBound"/>.</summary>
|
|
public TimeSpan ReplyBound => QueueBudget + RunBound;
|
|
|
|
/// <summary><see cref="DriverHostActor"/>'s Ask of the child: <see cref="ReplyBound"/> + <see cref="ReplyMargin"/>.</summary>
|
|
public TimeSpan HostAsk => ReplyBound + ReplyMargin;
|
|
|
|
/// <summary><see cref="ActorNodeWriteGateway"/>'s Ask of the host: <see cref="HostAsk"/> + <see cref="ReplyMargin"/>.</summary>
|
|
public TimeSpan GatewayAsk => HostAsk + ReplyMargin;
|
|
|
|
/// <summary>See <see cref="AbandonGrace"/>.</summary>
|
|
/// <param name="timeout">The write timeout in force.</param>
|
|
/// <returns>The grace between cancelling the write's token and abandoning the write.</returns>
|
|
public static TimeSpan AbandonGraceFor(TimeSpan timeout) =>
|
|
TimeSpan.FromTicks(Math.Min(timeout.Ticks / 2, TimeSpan.FromSeconds(2).Ticks));
|
|
}
|