revert(drivers): remove the periodic 5-minute device re-browse (#523)
Removes the tag-set drift detector shipped ine77c8a35. The design was sound -- the tautology drivers were correctly gated out, and the structurally undetectable "vanished" direction was declared rather than faked -- but none of that is the objection. The feature browsed real plant equipment on a recurring timer, at a frequency no operator could configure (the interval was a constructor parameter with no appsettings key), and that outbound traffic was never once observed against a real PLC, CNC or MTConnect Agent. Removed: TagSetDrift/TagSetDriftDetector; DriverInstanceActor's drift timer, tick message, Props/ctor parameter, gate, handler and leaf-collector; ITagDiscovery.AuthoredDiscoveryRefs + .DiscoveryStreamIncludesAuthoredTags and their four implementations; 14 tests. Kept: ITagDiscovery.SupportsOnlineDiscovery (it predates this and drives the universal browse picker), and the whole IRediscoverable consumption from09a401b8-- the driver tells us, we do not poll. CONSEQUENCE, deliberately accepted: AbCip and FOCAS browse a live backend but implement no IRediscoverable, so they now have NO tag-change signal at all -- a PLC re-download is invisible until someone re-browses by hand. Recorded in CLAUDE.md so the next reader does not have to rediscover it, along with the shape to reach for if that coverage is wanted back (event-driven, or triggered by a reconnect/deploy, not a wall-clock timer). Runtime.Tests 476 pass / 13 skipped (skip set unchanged); FOCAS 275, TwinCAT 192, MTConnect 491, AbCip 342, Core.Abstractions 266 all green. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Event;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
@@ -33,11 +32,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
public static readonly TimeSpan DefaultReconnectInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>Default period of the tag-set drift check. Deliberately slow: it browses a real device, and
|
||||
/// a tag set changing is an engineering event (a PLC re-download, a CNC option install), not a runtime
|
||||
/// one. Five minutes bounds the wire cost while still surfacing drift the same shift it happens.</summary>
|
||||
public static readonly TimeSpan DefaultDriftCheckInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
public sealed record InitializeRequested(string DriverConfigJson);
|
||||
public sealed record InitializeSucceeded(int Generation);
|
||||
public sealed record InitializeFailed(string Reason, int Generation);
|
||||
@@ -127,15 +121,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// between connects), and dropping it in one state would lose the signal silently.</summary>
|
||||
private sealed record RediscoveryRaised(RediscoveryEventArgs Args);
|
||||
|
||||
/// <summary>Self-sent on a slow timer to re-browse a genuinely-browsable driver and compare what the
|
||||
/// remote offers against what an operator authored. Only ever scheduled for a driver that opts in — see
|
||||
/// <see cref="QualifiesForDriftCheck"/>.</summary>
|
||||
private sealed record DriftCheckTick
|
||||
{
|
||||
public static readonly DriftCheckTick Instance = new();
|
||||
private DriftCheckTick() { }
|
||||
}
|
||||
|
||||
public sealed class RetryConnect
|
||||
{
|
||||
public static readonly RetryConnect Instance = new();
|
||||
@@ -196,14 +181,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
private DateTime? _rediscoveryNeededUtc;
|
||||
private string? _rediscoveryReason;
|
||||
|
||||
/// <summary>Period of the tag-set drift check; tests inject a tiny value.</summary>
|
||||
private readonly TimeSpan _driftCheckInterval;
|
||||
|
||||
/// <summary>Signature of the last drift REPORTED, so an unchanged drift is not re-announced on every
|
||||
/// check. Without this the operator's prompt would re-stamp its timestamp every interval forever,
|
||||
/// which reads as "it just happened again" rather than "it is still true".</summary>
|
||||
private string? _lastDriftSignature;
|
||||
|
||||
/// <summary>The references the host wants kept subscribed (set by <see cref="SetDesiredSubscriptions"/>).
|
||||
/// Re-applied on every entry into <c>Connected</c> so values resume after a reconnect or redeploy.</summary>
|
||||
private IReadOnlyList<string> _desiredRefs = Array.Empty<string>();
|
||||
@@ -237,8 +214,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// defaults to an empty string when not provided (e.g. in unit tests).</param>
|
||||
/// <param name="invoker">Optional Phase 6.1 resilience invoker wrapping this driver's capability
|
||||
/// calls; defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when not supplied.</param>
|
||||
/// <param name="driftCheckInterval">Optional period of the tag-set drift check; defaults to
|
||||
/// <see cref="DefaultDriftCheckInterval"/>. Tests inject a tiny value so the check runs without a wait.</param>
|
||||
/// <param name="healthPollInterval">Optional period of the health-poll heartbeat, which also drives the
|
||||
/// subscription reconcile (<see cref="ReconcileSubscription"/>); defaults to
|
||||
/// <see cref="HealthPollInterval"/>. Exists so a test can drive the reconcile without waiting 30 s.</param>
|
||||
@@ -250,8 +225,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
string? clusterId = null,
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null,
|
||||
TimeSpan? driftCheckInterval = null) =>
|
||||
TimeSpan? healthPollInterval = null) =>
|
||||
Akka.Actor.Props.Create(() => new DriverInstanceActor(
|
||||
driver,
|
||||
reconnectInterval ?? DefaultReconnectInterval,
|
||||
@@ -259,8 +233,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
healthPublisher ?? NullDriverHealthPublisher.Instance,
|
||||
clusterId ?? string.Empty,
|
||||
invoker,
|
||||
healthPollInterval,
|
||||
driftCheckInterval));
|
||||
healthPollInterval));
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
|
||||
@@ -291,8 +264,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <param name="clusterId">Cluster identifier forwarded in health snapshots.</param>
|
||||
/// <param name="invoker">Phase 6.1 resilience invoker wrapping this driver's capability calls;
|
||||
/// defaults to <see cref="NullDriverCapabilityInvoker"/> (pass-through) when null.</param>
|
||||
/// <param name="driftCheckInterval">Period of the tag-set drift check; defaults to
|
||||
/// <see cref="DefaultDriftCheckInterval"/>.</param>
|
||||
/// <param name="healthPollInterval">Period of the health-poll heartbeat, which also drives the
|
||||
/// subscription reconcile; defaults to <see cref="HealthPollInterval"/> when null.</param>
|
||||
public DriverInstanceActor(
|
||||
@@ -302,15 +273,13 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
IDriverHealthPublisher? healthPublisher = null,
|
||||
string? clusterId = null,
|
||||
IDriverCapabilityInvoker? invoker = null,
|
||||
TimeSpan? healthPollInterval = null,
|
||||
TimeSpan? driftCheckInterval = null)
|
||||
TimeSpan? healthPollInterval = null)
|
||||
{
|
||||
_driver = driver;
|
||||
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
|
||||
_driverInstanceId = driver.DriverInstanceId;
|
||||
_clusterId = clusterId ?? string.Empty;
|
||||
_healthPublisher = healthPublisher ?? NullDriverHealthPublisher.Instance;
|
||||
_driftCheckInterval = driftCheckInterval ?? DefaultDriftCheckInterval;
|
||||
_reconnectInterval = reconnectInterval;
|
||||
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
|
||||
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
|
||||
@@ -381,7 +350,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
SubscribeDesiredAlarms();
|
||||
StartDriftCheck();
|
||||
});
|
||||
Receive<InitializeFailed>(msg =>
|
||||
{
|
||||
@@ -427,7 +395,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_driverInstanceId, msg.Reason);
|
||||
DetachSubscription();
|
||||
RecordFault();
|
||||
StopDriftCheck();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
});
|
||||
@@ -435,7 +402,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: ForceReconnect requested by admin; re-entering Reconnecting", _driverInstanceId);
|
||||
DetachSubscription();
|
||||
StopDriftCheck();
|
||||
Become(Reconnecting);
|
||||
PublishHealthSnapshot();
|
||||
});
|
||||
@@ -458,7 +424,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
SubscribeDesiredAlarms();
|
||||
});
|
||||
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
|
||||
ReceiveAsync<DriftCheckTick>(HandleDriftCheckAsync);
|
||||
Receive<DataChangeForward>(OnDataChangeForward);
|
||||
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
|
||||
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
|
||||
@@ -553,7 +518,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
ResubscribeDesired();
|
||||
AttachAlarmSource();
|
||||
SubscribeDesiredAlarms();
|
||||
StartDriftCheck();
|
||||
});
|
||||
// A failure here is a no-op regardless of generation — the retry timer keeps trying the
|
||||
// current config; only a (generation-matched) InitializeSucceeded transitions state.
|
||||
@@ -798,10 +762,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops the drift check. Called on every Connected exit — browsing a disconnected driver would
|
||||
/// fail every pass, and a failed browse is deliberately NOT treated as drift.</summary>
|
||||
private void StopDriftCheck() => Timers.Cancel("drift-check");
|
||||
|
||||
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
|
||||
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
|
||||
/// push data-change / alarm events to an actor that has lost its driver connection.</summary>
|
||||
@@ -849,119 +809,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
_rediscoveryHandler = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this driver is worth drift-checking: it must genuinely BROWSE its remote
|
||||
/// (<see cref="ITagDiscovery.SupportsOnlineDiscovery"/>) and must report the refs its authored tags
|
||||
/// bind (<see cref="ITagDiscovery.AuthoredDiscoveryRefs"/>).
|
||||
/// <para>The first condition is what makes the check meaningful rather than reassuring. Most drivers
|
||||
/// — Modbus, S7, AbLegacy, Sql, Mqtt — stream their AUTHORED tags back out of <c>DiscoverAsync</c>
|
||||
/// rather than enumerating the device, so diffing the two sets for them is a tautology that would
|
||||
/// report "no drift" forever. A check that cannot fail is worse than no check: it looks like
|
||||
/// coverage.</para>
|
||||
/// </summary>
|
||||
private bool QualifiesForDriftCheck() =>
|
||||
_driver is ITagDiscovery { SupportsOnlineDiscovery: true, AuthoredDiscoveryRefs: not null };
|
||||
|
||||
/// <summary>Starts the slow drift check on a Connected entry, for qualifying drivers only.</summary>
|
||||
private void StartDriftCheck()
|
||||
{
|
||||
if (!QualifiesForDriftCheck()) return;
|
||||
// Periodic, not fire-on-connect: a driver whose discovered shape fills in asynchronously after
|
||||
// connect (the FOCAS FixedTree) would otherwise be compared against a half-populated browse and
|
||||
// report phantom drift on every reconnect.
|
||||
Timers.StartPeriodicTimer("drift-check", DriftCheckTick.Instance, _driftCheckInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-browses the remote and compares it against the driver's authored refs. A CHANGED drift raises
|
||||
/// the same operator prompt an <see cref="IRediscoverable"/> raise does — this is the signal for the
|
||||
/// browsable drivers that have no native change notification (AbCip, FOCAS), where a periodic
|
||||
/// compare is the only way to notice a PLC program re-download.
|
||||
/// <para>Advisory, like every rediscovery signal: the served address space is not touched. Raw tags
|
||||
/// are authored through the <c>/raw</c> browse-commit flow, so an operator re-browses and commits.</para>
|
||||
/// </summary>
|
||||
private async Task HandleDriftCheckAsync(DriftCheckTick _)
|
||||
{
|
||||
if (_driver is not ITagDiscovery discovery) return;
|
||||
var authored = discovery.AuthoredDiscoveryRefs;
|
||||
if (authored is null) return;
|
||||
|
||||
CapturedTree tree;
|
||||
try
|
||||
{
|
||||
var builder = new CapturingAddressSpaceBuilder();
|
||||
// Bounded: ReceiveAsync suspends the mailbox for the whole handler, so an unbounded browse would
|
||||
// block writes, reconnects and health polls behind it.
|
||||
using var cts = new CancellationTokenSource(DriftBrowseTimeout);
|
||||
await _invoker.ExecuteAsync(
|
||||
DriverCapability.Discover,
|
||||
_driverInstanceId,
|
||||
async ct => await discovery.DiscoverAsync(builder, ct),
|
||||
cts.Token);
|
||||
tree = builder.Build();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A failed browse is not drift — the device may simply be unreachable, which the health surface
|
||||
// already reports. Reporting drift here would turn every comms blip into "all your tags vanished".
|
||||
_log.Debug(ex, "DriverInstance {Id}: drift check browse failed; skipping this pass", _driverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tree.Truncated)
|
||||
{
|
||||
// A truncated capture is a PARTIAL view, so every un-captured authored ref would look vanished.
|
||||
_log.Warning(
|
||||
"DriverInstance {Id}: drift check skipped — the browse hit the capture cap and is partial",
|
||||
_driverInstanceId);
|
||||
return;
|
||||
}
|
||||
|
||||
var discovered = new List<string>();
|
||||
CollectLeafRefs(tree.Root, discovered);
|
||||
var drift = TagSetDriftDetector.Compare(
|
||||
discovered, authored, detectVanished: !discovery.DiscoveryStreamIncludesAuthoredTags);
|
||||
|
||||
if (!drift.HasDrift)
|
||||
{
|
||||
// Recovered: the device matches the authored set again, so drop the prompt and let the next
|
||||
// genuine drift re-raise with a fresh timestamp.
|
||||
if (_lastDriftSignature is not null)
|
||||
{
|
||||
_log.Info("DriverInstance {Id}: tag-set drift cleared — device matches the authored set",
|
||||
_driverInstanceId);
|
||||
_lastDriftSignature = null;
|
||||
_rediscoveryNeededUtc = null;
|
||||
_rediscoveryReason = null;
|
||||
PublishHealthSnapshot();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Report a CHANGED drift once. An unchanged one re-stamping its timestamp every interval would read
|
||||
// as "it just happened again" rather than "it is still true".
|
||||
if (string.Equals(_lastDriftSignature, drift.Signature, StringComparison.Ordinal)) return;
|
||||
_lastDriftSignature = drift.Signature;
|
||||
_rediscoveryNeededUtc = DateTime.UtcNow;
|
||||
_rediscoveryReason = "device tag set differs from authored: " + drift.Describe();
|
||||
_log.Info(
|
||||
"DriverInstance {Id}: tag-set drift detected ({Drift}) — surfaced for operator re-browse; the served address space is unchanged",
|
||||
_driverInstanceId, drift.Describe());
|
||||
PublishHealthSnapshot();
|
||||
}
|
||||
|
||||
/// <summary>Per-pass timeout for the drift browse. Bounds the mailbox suspension.</summary>
|
||||
private static readonly TimeSpan DriftBrowseTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Flattens a captured tree to its leaf ids — the driver-side <c>FullName</c>s, which is the
|
||||
/// vocabulary <c>AuthoredDiscoveryRefs</c> is defined in.</summary>
|
||||
private static void CollectLeafRefs(CapturedNode node, List<string> into)
|
||||
{
|
||||
if (node.IsLeaf) into.Add(node.Id);
|
||||
foreach (var child in node.Children) CollectLeafRefs(child, into);
|
||||
}
|
||||
|
||||
/// <summary>Records the driver's rediscovery raise and re-publishes health so the signal reaches the
|
||||
/// AdminUI promptly rather than waiting for the next 30 s heartbeat.
|
||||
/// <para><b>Advisory only.</b> The served address space is deliberately NOT rebuilt: v3 authors raw tags
|
||||
@@ -1137,7 +984,6 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
|
||||
/// <inheritdoc />
|
||||
protected override void PostStop()
|
||||
{
|
||||
StopDriftCheck();
|
||||
DetachSubscription();
|
||||
// MUST happen: the IDriver instance can outlive this actor (the host respawns a child around the
|
||||
// same driver object), so a missing unsubscribe accumulates a handler per respawn holding a dead Self.
|
||||
|
||||
Reference in New Issue
Block a user