fix(runtime): rate-limit the supervision reprovision — deferred, not dropped (review F3)
The default decider restarts a faulting child immediately and without limit, and the hook makes every restart do ShutdownAsync+InitializeAsync+SubscribeAsync, so a driver faulting on every value became a reconnect storm. Per-driver last-reprovision stamp + DefaultReprovisionBackoff (10s, matched to DefaultReconnectInterval); a held request is re-delivered by a single per-driver timer carrying the original incarnation ref, so the F2 identity check still applies. Seen RED with the backoff disabled: InitializeCount == 3 (second applied immediately) where the test requires 2-then-3. Also: the _children.Keys union-arm test (seeder gained taglessDrivers) and the _desiredByDriver xmldoc note that it lingers on last-known-good after a failed apply.
This commit is contained in:
@@ -478,6 +478,115 @@ public sealed class DriverHostActorRestartTests : RuntimeActorTestBase
|
||||
"concurrent InitializeAsync on a driver that was already connected");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>Review finding F3 - a crash-looping driver must not become a reconnect storm.</b> There is no
|
||||
/// <c>SupervisorStrategy</c> anywhere under <c>src/Server</c>, so the default decider restarts a
|
||||
/// faulting child <b>immediately and without limit</b>. That was survivable while a restart did
|
||||
/// nothing; the reprovision hook now makes every one of them do
|
||||
/// <c>ShutdownAsync</c> + <c>InitializeAsync</c> + <c>SubscribeAsync</c>, so a driver that faults on
|
||||
/// every value would hammer its device as fast as it can crash.
|
||||
/// <para>
|
||||
/// The reprovision is therefore rate-limited per driver. Crucially it is <b>deferred, not
|
||||
/// dropped</b> - the last request must still be honoured, or a child whose final crash fell
|
||||
/// inside the window would be left un-provisioned forever, which is the very failure this whole
|
||||
/// plan exists to close. Two genuine back-to-back supervision restarts: the first reprovision
|
||||
/// applies immediately, the second is held and then lands.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_second_reprovision_inside_the_backoff_window_is_deferred_not_dropped()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
// Generous relative to the in-memory step time below, so "inside the window" is a decision rather
|
||||
// than a race, while still far under the presence budget the deferred landing waits on.
|
||||
var backoff = TimeSpan.FromSeconds(3);
|
||||
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
||||
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||
db, RevA,
|
||||
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
|
||||
Array.Empty<(string, string, string, string, string?)>(),
|
||||
enabled: true);
|
||||
V3ArtifactSeeder.SpawnHostAndApply(
|
||||
this, db, TestNode, deploymentId, RevA, factory, reprovisionBackoff: backoff);
|
||||
|
||||
AwaitAssert(
|
||||
() =>
|
||||
{
|
||||
factory.Created.Count.ShouldBe(1);
|
||||
factory.Created[0].SubscribeCount.ShouldBe(1);
|
||||
},
|
||||
duration: PresenceBudget);
|
||||
var driver = factory.Created[0];
|
||||
|
||||
// Crash 1: the first reprovision has no prior stamp, so it applies immediately.
|
||||
driver.FireMalformedDataChange(SpeedPath);
|
||||
AwaitAssert(
|
||||
() =>
|
||||
{
|
||||
driver.InitializeCount.ShouldBe(2);
|
||||
driver.SubscribeCount.ShouldBe(2, "the re-subscribe must land before the second crash is armed");
|
||||
},
|
||||
duration: PresenceBudget);
|
||||
|
||||
// Crash 2, immediately after - well inside the window.
|
||||
driver.FireMalformedDataChange(SpeedPath);
|
||||
AwaitAssert(
|
||||
() => driver.ShutdownCount.ShouldBeGreaterThanOrEqualTo(
|
||||
2, "the second crash seam did not trip supervision - nothing below is testing a backoff"),
|
||||
duration: PresenceBudget);
|
||||
|
||||
driver.InitializeCount.ShouldBe(
|
||||
2, "the second reprovision was applied immediately - the backoff is not rate-limiting anything");
|
||||
|
||||
// ...but it must still land once the window elapses.
|
||||
AwaitAssert(
|
||||
() => driver.InitializeCount.ShouldBe(
|
||||
3, "the deferred reprovision was DROPPED, not deferred"),
|
||||
duration: PresenceBudget);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>_children.Keys</c> arm of the desired-set authority's union. A driver with NO authored tags
|
||||
/// is absent from both the value- and alarm-ref maps, so keying the authority off those maps alone
|
||||
/// would leave it with no entry at all — and "absent" must not be allowed to differ from "empty",
|
||||
/// because an empty set is what tells a child to <c>Unsubscribe</c> and drop a stale handle.
|
||||
/// <para>
|
||||
/// Asserted the only way it is observable: the tagless child is initialised (so it is provably
|
||||
/// alive and was provisioned) and never subscribes, while its tag-owning sibling in the same
|
||||
/// deployment does — the sibling is the positive control proving the apply's push pass actually
|
||||
/// ran, so "it did not subscribe" cannot pass for "nothing happened".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_running_driver_with_no_authored_tags_is_handed_an_empty_set()
|
||||
{
|
||||
var db = NewInMemoryDbFactory();
|
||||
var factory = new RecordingSubscribableDriverFactory("Modbus");
|
||||
var deploymentId = V3ArtifactSeeder.SeedV3Deployment(
|
||||
db, RevA,
|
||||
new[] { (DriverId, "Modbus", "dev1", "speed", "Float", false) },
|
||||
Array.Empty<(string, string, string, string, string?)>(),
|
||||
enabled: true,
|
||||
taglessDrivers: new[] { "DI-TAGLESS" });
|
||||
V3ArtifactSeeder.SpawnHostAndApply(this, db, TestNode, deploymentId, RevA, factory);
|
||||
|
||||
AwaitAssert(
|
||||
() =>
|
||||
{
|
||||
factory.Created.Count.ShouldBe(2);
|
||||
// Positive control: the sibling that DOES have a tag subscribed, so the push pass ran.
|
||||
factory.Created.Single(d => d.DriverInstanceId == DriverId)
|
||||
.SubscribeCount.ShouldBe(1);
|
||||
factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS")
|
||||
.InitializeCount.ShouldBe(1);
|
||||
},
|
||||
duration: PresenceBudget);
|
||||
|
||||
factory.Created.Single(d => d.DriverInstanceId == "DI-TAGLESS")
|
||||
.SubscribeCount.ShouldBe(
|
||||
0, "a driver with no authored tags must be handed an EMPTY set, never a spurious subscribe");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The verified negative, pinned so this plan's edits to the restart paths cannot break the one that
|
||||
/// was already correct. Force Reconnect (<c>ReconnectDriver</c>) Tells <c>ForceReconnect</c> to the
|
||||
|
||||
@@ -47,18 +47,25 @@ public static class V3ArtifactSeeder
|
||||
/// is parsed out of <c>TagConfig.alarm</c> by <c>TagConfigIntent</c>), which the alarm-ref half of the
|
||||
/// restart suite needs — an alarm-bearing raw tag is excluded from the VALUE subscription set and carried
|
||||
/// in the alarm one instead, so the two travel through different child code paths.</param>
|
||||
/// <param name="taglessDrivers">Extra <c>DriverInstance</c> ids carrying NO devices and NO tags. The
|
||||
/// only way to express a driver that is a running child yet absent from both the value- and alarm-ref
|
||||
/// maps — the <c>_children.Keys</c> arm of <c>ApplyDesiredSubscriptions</c>'s union, which must still
|
||||
/// hand that child an explicit EMPTY set rather than skipping it.</param>
|
||||
/// <returns>The seeded deployment's id.</returns>
|
||||
public static DeploymentId SeedV3Deployment(
|
||||
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
|
||||
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
|
||||
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs,
|
||||
bool enabled = false,
|
||||
IReadOnlyDictionary<string, string>? tagConfigByTag = null)
|
||||
IReadOnlyDictionary<string, string>? tagConfigByTag = null,
|
||||
string[]? taglessDrivers = null)
|
||||
{
|
||||
var drivers = rawTags
|
||||
.Select(t => (t.Driver, t.DriverName))
|
||||
.Distinct()
|
||||
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled })
|
||||
.Concat((taglessDrivers ?? Array.Empty<string>())
|
||||
.Select(id => new { DriverInstanceId = id, RawFolderId = "rf-plant", Name = id, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = enabled }))
|
||||
.ToArray();
|
||||
var devices = rawTags
|
||||
.Select(t => (t.Driver, t.Device))
|
||||
@@ -130,14 +137,18 @@ public static class V3ArtifactSeeder
|
||||
/// <param name="driverFactory">Optional driver factory forwarded to <c>DriverHostActor.Props</c>. Null (the
|
||||
/// default, preserving every existing call site) leaves the host on its own default, which materialises no
|
||||
/// real drivers. Supply one — with <c>enabled: true</c> on the seed — to actually spawn driver children.</param>
|
||||
/// <param name="reprovisionBackoff">Optional per-driver supervision-reprovision rate limit forwarded to
|
||||
/// <c>DriverHostActor.Props</c>; null keeps the production default. Exists so a test can observe the
|
||||
/// deferral without waiting the production window.</param>
|
||||
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
|
||||
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
|
||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||
NodeId localNode, DeploymentId deploymentId, RevisionHash rev,
|
||||
IDriverFactory? driverFactory = null)
|
||||
IDriverFactory? driverFactory = null,
|
||||
TimeSpan? reprovisionBackoff = null)
|
||||
{
|
||||
var publish = kit.CreateTestProbe();
|
||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, driverFactory);
|
||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref, driverFactory, reprovisionBackoff);
|
||||
|
||||
Dispatch(actor, coordinator, deploymentId, rev);
|
||||
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
|
||||
@@ -157,14 +168,14 @@ public static class V3ArtifactSeeder
|
||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
|
||||
NodeId localNode, DeploymentId deploymentId, RevisionHash rev)
|
||||
{
|
||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null, driverFactory: null);
|
||||
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null, driverFactory: null, reprovisionBackoff: null);
|
||||
Dispatch(actor, coordinator, deploymentId, rev);
|
||||
return (actor, mux);
|
||||
}
|
||||
|
||||
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
|
||||
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
|
||||
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory)
|
||||
IActorRef? opcUaPublishActor, IDriverFactory? driverFactory, TimeSpan? reprovisionBackoff)
|
||||
{
|
||||
var coordinator = kit.CreateTestProbe();
|
||||
var mux = kit.CreateTestProbe();
|
||||
@@ -177,7 +188,8 @@ public static class V3ArtifactSeeder
|
||||
dependencyMux: mux.Ref,
|
||||
opcUaPublishActor: opcUaPublishActor,
|
||||
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
|
||||
virtualTagHostOverride: vtHost.Ref));
|
||||
virtualTagHostOverride: vtHost.Ref,
|
||||
reprovisionBackoff: reprovisionBackoff));
|
||||
|
||||
return (actor, mux, coordinator);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user