09a401b881
Nine drivers raise IRediscoverable.OnRediscoveryNeeded when they observe that a remote's tag set may have changed — a Galaxy redeploy, a TwinCAT symbol-version bump, an MTConnect agent restart, a Sparkplug rebirth. Nothing in src/ subscribed, so every raise went into the void. Drivers even wrap the invoke in try/catch for "subscriber threw"; there has never been a subscriber. DriverInstanceActor now attaches the event in PreStart and detaches in PostStop, mirroring AttachAlarmSource/DetachAlarmSource. Attach is per-actor, not per-connect: the raise has no connection affinity and can land while the driver is between connects. The detach is load-bearing rather than tidy — the IDriver instance outlives the actor when the host respawns a child around the same driver object, so a missing -= accumulates one handler per respawn, each holding a dead Self. The signal rides the existing driver-health snapshot to /hosts as a "re-browse" chip. It is ADVISORY: the served address space is deliberately not rebuilt, because v3 authors raw tags explicitly through the /raw browse-commit flow and a runtime graft would materialise nodes no operator approved and no deployment artifact records. Two things worth knowing: - PublishHealthSnapshot dedups on a health fingerprint, and a rediscovery raise on an otherwise-healthy driver changes none of the four fields it hashed. Without adding the timestamp to that tuple the dedup swallows the exact publish carrying the signal. Reverting that one line turns 3 of the 5 new tests red, which is how it was confirmed rather than assumed. - The new test stub implements IDriver from scratch instead of deriving from the shared StubDriver, whose GetHealth() returns DateTime.UtcNow — its fingerprint differs on every call, so the dedup never engages and the dedup test would have passed vacuously either way. The planned discovery-result drift detector was NOT built. Modbus, S7, MQTT, AbLegacy and Sql all echo authored config from DiscoverAsync rather than browsing the device, so a discovered-vs-authored diff is permanently empty for them — it would have been another plausible-looking inert seam, which is the class this audit exists to remove. IRediscoverable is the trustworthy signal because the driver asserts it deliberately. DriverHealthChanged, IDriverHealthPublisher.Publish and HostsDriverRow gain defaulted optional parameters, so no existing call site changed. telemetry.proto gains fields 8/9 and both Phase-5 mappers carry them. Runtime.Tests 512 passed / 31 skipped.
129 lines
6.1 KiB
C#
129 lines
6.1 KiB
C#
using Akka.Actor;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Gitea #488 — the periodic desired-vs-actual subscription reconcile that rides
|
|
/// <see cref="DriverInstanceActor"/>'s existing <c>health-poll</c> tick.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The hole it closes: the desired ref set is re-applied on a <b>Connected transition</b> and on a
|
|
/// deploy's <c>SetDesiredSubscriptions</c>, and nowhere else. A subscription lost while the driver
|
|
/// <i>stayed</i> Connected — the shape reproduced here, a subscribe that failed and was never
|
|
/// retried — left the actor permanently subscribed to nothing while reporting Healthy.
|
|
/// </para>
|
|
/// <para>
|
|
/// All three tests drive a short <c>healthPollInterval</c> rather than the production 30 s. The
|
|
/// two absence assertions are bounded by a positive control (a counted number of ticks provably
|
|
/// processed), never by a bare sleep.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DriverInstanceActorSubscriptionReconcileTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(100);
|
|
|
|
/// <summary>
|
|
/// The reconcile itself: the first subscribe fails, leaving the actor Connected with a desired set
|
|
/// and no handle. Nothing else would ever retry it — no reconnect, no deploy — so a second
|
|
/// subscribe can only come from the periodic reconcile.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_lost_subscription_is_re_established_while_still_connected()
|
|
{
|
|
var driver = new SubscribableStubDriver { SubscribeFailuresBeforeSuccess = 1 };
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(driver, healthPollInterval: FastPoll));
|
|
|
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
|
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
|
|
// The connect-path subscribe runs and throws, so the handle stays null.
|
|
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
|
|
|
|
// Only the reconcile can produce this second attempt — and it must succeed this time.
|
|
AwaitCondition(() => driver.SubscribeCount >= 2, PresenceBudget);
|
|
driver.LastSubscribedRefs.ShouldBe(new[] { "tag-a" });
|
|
}
|
|
|
|
/// <summary>
|
|
/// A healthy subscription is left alone — the reconcile must not re-subscribe on every tick, which
|
|
/// would churn the backend every 30 s in production for no reason.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_healthy_subscription_is_not_re_subscribed_on_every_tick()
|
|
{
|
|
var publisher = new CountingHealthPublisher();
|
|
var driver = new SubscribableStubDriver();
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(
|
|
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
|
|
|
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
|
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
AwaitCondition(() => driver.SubscribeCount >= 1, PresenceBudget);
|
|
|
|
// POSITIVE CONTROL for the absence below. Every health-poll tick publishes a snapshot, so the
|
|
// publisher count is a direct observation of ticks PROCESSED. Waiting for several of them is what
|
|
// makes "SubscribeCount is still 1" mean "the reconcile ran and declined" rather than "the timer
|
|
// had not fired yet" — the latter would pass instantly and prove nothing.
|
|
var before = publisher.Count;
|
|
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
|
|
|
|
driver.SubscribeCount.ShouldBe(1,
|
|
"the handle is live, so the reconcile must leave it alone");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A driver that is not <see cref="ISubscribable"/> can still be handed a desired set. Reconciling
|
|
/// it would tell <c>Subscribe</c> every tick and fail every tick, forever — so the reconcile is
|
|
/// gated on the capability.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_non_subscribable_driver_is_never_reconciled()
|
|
{
|
|
var publisher = new CountingHealthPublisher();
|
|
var driver = new StubDriver(); // IDriver only — no ISubscribable
|
|
var actor = Sys.ActorOf(DriverInstanceActor.Props(
|
|
driver, healthPublisher: publisher, healthPollInterval: FastPoll));
|
|
|
|
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions(
|
|
new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)));
|
|
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
|
|
|
|
// Same positive control: assert the reconcile stays silent across several PROCESSED ticks.
|
|
// EventFilter bounds the absence to this block, and the reconcile announces itself at Info —
|
|
// the first test above is the proof that this message does appear when the reconcile fires.
|
|
EventFilter.Info(contains: "periodic reconcile").Expect(0, () =>
|
|
{
|
|
var before = publisher.Count;
|
|
AwaitCondition(() => publisher.Count >= before + 4, PresenceBudget);
|
|
});
|
|
}
|
|
|
|
/// <summary>Counts health-snapshot publishes, which is one per <c>health-poll</c> tick once the actor
|
|
/// is running — the observable that turns "time passed" into "ticks were processed".</summary>
|
|
private sealed class CountingHealthPublisher : IDriverHealthPublisher
|
|
{
|
|
private int _count;
|
|
|
|
/// <summary>Number of snapshots published so far.</summary>
|
|
public int Count => Volatile.Read(ref _count);
|
|
|
|
/// <inheritdoc />
|
|
public void Publish(
|
|
string clusterId,
|
|
string driverInstanceId,
|
|
DriverHealth health,
|
|
int errorCount5Min,
|
|
DateTime? rediscoveryNeededUtc = null,
|
|
string? rediscoveryReason = null)
|
|
=> Interlocked.Increment(ref _count);
|
|
}
|
|
}
|