fix(galaxy): #875 Galaxy initialize gets a 20 s budget via per-driver-type capability defaults

Gate F (#761) against the VD03 gateway: Galaxy initialize (session open +
platform seed) took 4–5 s against Tier A's 5 s Initialize timeout, so 10 of 40
restarts failed, paid a 10 s retry and left a killed gateway worker.

DriverFactoryRegistry.Register takes optional capabilityDefaults per driver
type (GetCapabilityDefaults); DriverResilienceOptionsParser layers them over the
tier table and under the per-instance ResilienceConfig; the Host wires the
registry into DriverCapabilityInvokerFactory. Galaxy registers Initialize =
20 s (no retry, breaker 2), under the actor's 30 s pessimistic bound.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 18:44:44 -05:00
parent 2be43aa984
commit 95bde828eb
8 changed files with 157 additions and 7 deletions
@@ -1,3 +1,4 @@
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
@@ -21,6 +22,8 @@ public sealed class DriverFactoryRegistry
= new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, DriverTier> _tiers
= new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, IReadOnlyDictionary<DriverCapability, CapabilityPolicy>> _capabilityDefaults
= new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
/// <summary>
@@ -42,8 +45,14 @@ public sealed class DriverFactoryRegistry
/// <c>DriverInstanceBootstrapper</c> can skip in-process drivers by tier check
/// rather than by driver-type allow-list.
/// </param>
/// <param name="capabilityDefaults">
/// Optional per-driver-TYPE capability policies layered over the tier defaults (and under any
/// per-instance <c>ResilienceConfig</c>) — for a driver whose real cost of one capability does not fit its
/// tier's table, e.g. Galaxy's initialize (session open + platform seed) against a real gateway (#875).
/// </param>
public void Register(string driverType, Func<string, string, IDriver> factory,
DriverTier tier = DriverTier.A)
DriverTier tier = DriverTier.A,
IReadOnlyDictionary<DriverCapability, CapabilityPolicy>? capabilityDefaults = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
ArgumentNullException.ThrowIfNull(factory);
@@ -54,6 +63,7 @@ public sealed class DriverFactoryRegistry
$"DriverType '{driverType}' factory already registered for this process");
_factories[driverType] = factory;
_tiers[driverType] = tier;
if (capabilityDefaults is { Count: > 0 }) _capabilityDefaults[driverType] = capabilityDefaults;
}
}
@@ -83,6 +93,15 @@ public sealed class DriverFactoryRegistry
lock (_lock) return _tiers.GetValueOrDefault(driverType, DriverTier.A);
}
/// <summary>The per-driver-type capability policy defaults registered with the factory, or null (#875).</summary>
/// <param name="driverType">The driver type to look up.</param>
/// <returns>The registered defaults, or <see langword="null"/> when the type registered none.</returns>
public IReadOnlyDictionary<DriverCapability, CapabilityPolicy>? GetCapabilityDefaults(string driverType)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
lock (_lock) return _capabilityDefaults.GetValueOrDefault(driverType);
}
/// <summary>Gets the collection of registered driver type names.</summary>
public IReadOnlyCollection<string> RegisteredTypes
{
@@ -34,6 +34,7 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
private readonly Func<string, DriverTier> _tierResolver;
private readonly ILogger? _logger;
private readonly TimeSpan? _writeTimeoutCeiling;
private readonly Func<string, IReadOnlyDictionary<DriverCapability, CapabilityPolicy>?>? _typeDefaultsResolver;
private long _generation;
/// <summary>Construct the factory over the shared resilience infrastructure.</summary>
@@ -46,12 +47,15 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
/// cancels every write before the Write policy's own timeout can (lmxopcua#777). When non-null, a driver whose
/// effective Write timeout exceeds it is logged as a warning at <see cref="Create"/> time: that policy timeout
/// never takes effect. Null skips the check.</param>
/// <param name="typeDefaultsResolver">Resolves a driver type's registered capability defaults
/// (<c>DriverFactoryRegistry.GetCapabilityDefaults</c>, #875), layered over the tier table. Null = tier only.</param>
public DriverCapabilityInvokerFactory(
DriverResiliencePipelineBuilder builder,
DriverResilienceStatusTracker statusTracker,
Func<string, DriverTier> tierResolver,
ILogger? logger = null,
TimeSpan? writeTimeoutCeiling = null)
TimeSpan? writeTimeoutCeiling = null,
Func<string, IReadOnlyDictionary<DriverCapability, CapabilityPolicy>?>? typeDefaultsResolver = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(statusTracker);
@@ -61,13 +65,15 @@ public sealed class DriverCapabilityInvokerFactory : IDriverCapabilityInvokerFac
_tierResolver = tierResolver;
_logger = logger;
_writeTimeoutCeiling = writeTimeoutCeiling;
_typeDefaultsResolver = typeDefaultsResolver;
}
/// <inheritdoc />
public IDriverCapabilityInvoker Create(string driverInstanceId, string driverType, string? resilienceConfigJson = null)
{
var tier = _tierResolver(driverType);
var options = DriverResilienceOptionsParser.ParseOrDefaults(tier, resilienceConfigJson, out var diagnostic);
var options = DriverResilienceOptionsParser.ParseOrDefaults(
tier, resilienceConfigJson, _typeDefaultsResolver?.Invoke(driverType), out var diagnostic);
if (diagnostic is not null)
{
_logger?.LogWarning(
@@ -52,10 +52,32 @@ public static class DriverResilienceOptionsParser
public static DriverResilienceOptions ParseOrDefaults(
DriverTier tier,
string? resilienceConfigJson,
out string? parseDiagnostic) => ParseOrDefaults(tier, resilienceConfigJson, typeDefaults: null, out parseDiagnostic);
/// <summary>
/// As <see cref="ParseOrDefaults(DriverTier, string?, out string?)"/>, with the driver TYPE's registered
/// capability defaults (<c>DriverFactoryRegistry.GetCapabilityDefaults</c>, #875) layered over the tier
/// table before the per-instance JSON is applied.
/// </summary>
/// <param name="tier">The driver's stability tier.</param>
/// <param name="resilienceConfigJson">The per-instance ResilienceConfig JSON, or null.</param>
/// <param name="typeDefaults">The driver type's capability defaults, or null.</param>
/// <param name="parseDiagnostic">A diagnostic when the JSON was malformed or partly ignored.</param>
/// <returns>The effective options.</returns>
public static DriverResilienceOptions ParseOrDefaults(
DriverTier tier,
string? resilienceConfigJson,
IReadOnlyDictionary<DriverCapability, CapabilityPolicy>? typeDefaults,
out string? parseDiagnostic)
{
parseDiagnostic = null;
var baseDefaults = DriverResilienceOptions.GetTierDefaults(tier);
IReadOnlyDictionary<DriverCapability, CapabilityPolicy> baseDefaults = DriverResilienceOptions.GetTierDefaults(tier);
if (typeDefaults is { Count: > 0 })
{
var layered = new Dictionary<DriverCapability, CapabilityPolicy>(baseDefaults);
foreach (var (capability, policy) in typeDefaults) layered[capability] = policy;
baseDefaults = layered;
}
var baseOptions = new DriverResilienceOptions { Tier = tier, CapabilityPolicies = baseDefaults };
if (string.IsNullOrWhiteSpace(resilienceConfigJson))