feat(driver-calc): B2-WP7 Calculation driver — IDependencyConsumer + triggers + deploy cycle gate
Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live values) plus the host seam that feeds it: - IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation. - CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate + dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission, Good recovery; reuses the T0-4 CalculationEvaluator. - DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver, registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged → OnDependencyValue; re-registers on every apply, torn down with the driver. - Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test csproj references the driver so the bin-scan discovers the factory. - DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's delivered TagConfig so the host-blind driver can compile it. - DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges). - Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer, read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end. Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// Periodic re-evaluation scheduler for calc tags carrying a <c>timerIntervalMs</c>. A deliberate
|
||||
/// mirror of the VirtualTag <c>TimerTriggerScheduler</c> (<c>Core.VirtualTags</c>): one
|
||||
/// <see cref="System.Threading.Timer"/> per distinct interval group (so the wire count stays low
|
||||
/// regardless of tag count) with a per-group in-flight flag that skips a tick when the prior tick for
|
||||
/// the same group is still running. Revived <b>inside the driver</b> because the VT scheduler is welded
|
||||
/// to <c>VirtualTagEngine.EvaluateOneAsync</c>; here each tick invokes the driver's own evaluate
|
||||
/// callback per tag in the group.
|
||||
/// </summary>
|
||||
internal sealed class CalculationTimerScheduler : IDisposable
|
||||
{
|
||||
private readonly Action<string> _evaluate;
|
||||
private readonly ILogger _logger;
|
||||
private readonly List<Timer> _timers = [];
|
||||
private readonly List<TickGroup> _groups = [];
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private long _skippedTickCount;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CalculationTimerScheduler"/> class.</summary>
|
||||
/// <param name="evaluate">Callback invoked with a calc tag's RawPath when its timer group ticks.</param>
|
||||
/// <param name="logger">Logger for diagnostics.</param>
|
||||
public CalculationTimerScheduler(Action<string> evaluate, ILogger logger)
|
||||
{
|
||||
_evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>Number of timer callbacks that skipped their work because the prior tick for the same
|
||||
/// group was still running. Monotonic; exposed for tests + operational metrics.</summary>
|
||||
public long SkippedTickCount => Interlocked.Read(ref _skippedTickCount);
|
||||
|
||||
/// <summary>Stand up one <see cref="Timer"/> per distinct interval. All tags sharing an interval share
|
||||
/// a timer; each tick re-evaluates every tag in the group.</summary>
|
||||
/// <param name="tags">The calc tags to schedule (only those with a positive <see cref="CalculationTagDefinition.TimerInterval"/> are scheduled).</param>
|
||||
public void Start(IEnumerable<CalculationTagDefinition> tags)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(CalculationTimerScheduler));
|
||||
|
||||
var byInterval = tags
|
||||
.Where(t => t.TimerInterval is { } iv && iv > TimeSpan.Zero)
|
||||
.GroupBy(t => t.TimerInterval!.Value);
|
||||
|
||||
foreach (var group in byInterval)
|
||||
{
|
||||
var paths = group.Select(t => t.RawPath).ToArray();
|
||||
var interval = group.Key;
|
||||
var ctx = new TickGroup(paths);
|
||||
_groups.Add(ctx);
|
||||
var timer = new Timer(_ => OnTimer(ctx), null, interval, interval);
|
||||
_timers.Add(timer);
|
||||
_logger.LogInformation("CalculationTimerScheduler: {TagCount} tag(s) on {Interval} cadence",
|
||||
paths.Length, interval);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTimer(TickGroup ctx)
|
||||
{
|
||||
if (_cts.IsCancellationRequested) return;
|
||||
|
||||
// Skip the tick when the prior one for this group is still running — bounds the outstanding
|
||||
// work to one tick per group regardless of how long an evaluation takes.
|
||||
if (Interlocked.CompareExchange(ref ctx.InFlight, 1, 0) != 0)
|
||||
{
|
||||
Interlocked.Increment(ref _skippedTickCount);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var path in ctx.Paths)
|
||||
{
|
||||
if (_cts.IsCancellationRequested) return;
|
||||
try { _evaluate(path); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "CalculationTimerScheduler evaluate failed for {Path}", path); }
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref ctx.InFlight, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Releases all timers and disposes the scheduler's resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cts.Cancel();
|
||||
foreach (var t in _timers)
|
||||
{
|
||||
try { t.Dispose(); } catch { /* best-effort teardown */ }
|
||||
}
|
||||
_timers.Clear();
|
||||
_groups.Clear();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TickGroup(IReadOnlyList<string> paths)
|
||||
{
|
||||
// 0 = idle, 1 = a tick is currently running for this group.
|
||||
public int InFlight;
|
||||
public IReadOnlyList<string> Paths { get; } = paths;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user