Compare commits
17 Commits
phase-7-st
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6df069b083 | |||
|
|
0687bb2e2d | ||
| 4d4f08af0d | |||
|
|
f1f53e1789 | ||
| e97db2d108 | |||
|
|
be1003c53e | ||
| dccaa11510 | |||
|
|
25ad4b1929 | ||
| 51d0b27bfd | |||
|
|
df39809526 | ||
| 2a8bcc8f60 | |||
|
|
479af166ab | ||
| 00724e9784 | |||
|
|
36774842cf | ||
| cb5d7b2d58 | |||
|
|
0ae715cca4 | ||
| d2bfcd9f1e |
@@ -4,6 +4,9 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
@@ -28,6 +31,9 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests/ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests/ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
@page "/alarms/historian"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian
|
||||
@inject HistorianDiagnosticsService Diag
|
||||
|
||||
<h1>Alarm historian</h1>
|
||||
<p class="text-muted">Local store-and-forward queue that ships alarm events to Aveva Historian via Galaxy.Host.</p>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Drain state</small>
|
||||
<h4><span class="badge @BadgeFor(_status.DrainState)">@_status.DrainState</span></h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Queue depth</small>
|
||||
<h4>@_status.QueueDepth.ToString("N0")</h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Dead-letter depth</small>
|
||||
<h4 class="@(_status.DeadLetterDepth > 0 ? "text-warning" : "")">@_status.DeadLetterDepth.ToString("N0")</h4>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<small class="text-muted">Last success</small>
|
||||
<h4>@(_status.LastSuccessUtc?.ToString("u") ?? "—")</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_status.LastError))
|
||||
{
|
||||
<div class="alert alert-warning mt-3 mb-0">
|
||||
<strong>Last error:</strong> @_status.LastError
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" @onclick="RefreshAsync">Refresh</button>
|
||||
<button class="btn btn-warning" disabled="@(_status.DeadLetterDepth == 0)" @onclick="RetryDeadLetteredAsync">
|
||||
Retry dead-lettered (@_status.DeadLetterDepth)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_retryResult is not null)
|
||||
{
|
||||
<div class="alert alert-success mt-3">Requeued @_retryResult row(s) for retry.</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private HistorianSinkStatus _status = new(0, 0, null, null, null, HistorianDrainState.Disabled);
|
||||
private int? _retryResult;
|
||||
|
||||
protected override void OnInitialized() => _status = Diag.GetStatus();
|
||||
|
||||
private Task RefreshAsync()
|
||||
{
|
||||
_status = Diag.GetStatus();
|
||||
_retryResult = null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task RetryDeadLetteredAsync()
|
||||
{
|
||||
_retryResult = Diag.TryRetryDeadLettered();
|
||||
_status = Diag.GetStatus();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string BadgeFor(HistorianDrainState s) => s switch
|
||||
{
|
||||
HistorianDrainState.Idle => "bg-success",
|
||||
HistorianDrainState.Draining => "bg-info",
|
||||
HistorianDrainState.BackingOff => "bg-warning text-dark",
|
||||
HistorianDrainState.Disabled => "bg-secondary",
|
||||
_ => "bg-secondary",
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<li class="nav-item"><button class="nav-link @Active("namespaces")" @onclick='() => _tab = "namespaces"'>Namespaces</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("drivers")" @onclick='() => _tab = "drivers"'>Drivers</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("acls")" @onclick='() => _tab = "acls"'>ACLs</button></li>
|
||||
<li class="nav-item"><button class="nav-link @Active("scripts")" @onclick='() => _tab = "scripts"'>Scripts</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
@@ -32,6 +33,7 @@
|
||||
else if (_tab == "namespaces") { <NamespacesTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "drivers") { <DriversTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "acls") { <AclsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
else if (_tab == "scripts") { <ScriptsTab GenerationId="@GenerationId" ClusterId="@ClusterId"/> }
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card sticky-top">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@*
|
||||
Monaco-backed C# code editor (Phase 7 Stream F). Progressive enhancement:
|
||||
textarea renders immediately, Monaco mounts via JS interop after first render.
|
||||
Monaco script tags are loaded once from the parent layout (wwwroot/js/monaco-loader.js
|
||||
pulls the CDN bundle).
|
||||
|
||||
Stream F keeps the interop surface small — bind `Source` two-way, and the parent
|
||||
tab re-renders on change for the dependency preview. The test-harness button
|
||||
lives in the parent so one editor can drive multiple script types.
|
||||
*@
|
||||
|
||||
<div class="script-editor">
|
||||
<textarea class="form-control font-monospace" rows="14" spellcheck="false"
|
||||
@bind="Source" @bind:event="oninput" id="@_editorId">@Source</textarea>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Source { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> SourceChanged { get; set; }
|
||||
|
||||
private readonly string _editorId = $"script-editor-{Guid.NewGuid():N}";
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("otOpcUaScriptEditor.attach", _editorId);
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Monaco bundle not yet loaded on this page — textarea fallback is
|
||||
// still functional.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||
@using ZB.MOM.WW.OtOpcUa.Core.Scripting
|
||||
@inject ScriptService ScriptSvc
|
||||
@inject ScriptTestHarnessService Harness
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0">Scripts</h4>
|
||||
<small class="text-muted">C# (Roslyn). Used by virtual tags + scripted alarms.</small>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="StartNew">+ New script</button>
|
||||
</div>
|
||||
|
||||
<script src="/js/monaco-loader.js"></script>
|
||||
|
||||
@if (_loading) { <p class="text-muted">Loading…</p> }
|
||||
else if (_scripts.Count == 0 && _editing is null)
|
||||
{
|
||||
<div class="alert alert-info">No scripts yet in this draft.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="list-group">
|
||||
@foreach (var s in _scripts)
|
||||
{
|
||||
<button class="list-group-item list-group-item-action @(_editing?.ScriptId == s.ScriptId ? "active" : "")"
|
||||
@onclick="() => Open(s)">
|
||||
<strong>@s.Name</strong>
|
||||
<div class="small text-muted font-monospace">@s.ScriptId</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
@if (_editing is not null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>@(_isNew ? "New script" : _editing.Name)</strong>
|
||||
<div>
|
||||
@if (!_isNew)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger me-2" @onclick="DeleteAsync">Delete</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-primary" disabled="@_busy" @onclick="SaveAsync">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Name</label>
|
||||
<input class="form-control" @bind="_editing.Name"/>
|
||||
</div>
|
||||
<label class="form-label">Source</label>
|
||||
<ScriptEditor @bind-Source="_editing.SourceCode"/>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="PreviewDependencies">Analyze dependencies</button>
|
||||
<button class="btn btn-sm btn-outline-info ms-2" @onclick="RunHarnessAsync" disabled="@_harnessBusy">Run test harness</button>
|
||||
</div>
|
||||
|
||||
@if (_dependencies is not null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<strong>Inferred reads</strong>
|
||||
@if (_dependencies.Reads.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var r in _dependencies.Reads) { <li><code>@r</code></li> }
|
||||
</ul>
|
||||
}
|
||||
<strong>Inferred writes</strong>
|
||||
@if (_dependencies.Writes.Count == 0) { <span class="text-muted ms-2">none</span> }
|
||||
else
|
||||
{
|
||||
<ul class="mb-1">
|
||||
@foreach (var w in _dependencies.Writes) { <li><code>@w</code></li> }
|
||||
</ul>
|
||||
}
|
||||
@if (_dependencies.Rejections.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mt-2">
|
||||
<strong>Non-literal paths rejected:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var r in _dependencies.Rejections) { <li>@r.Message</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_testResult is not null)
|
||||
{
|
||||
<div class="mt-3 border-top pt-3">
|
||||
<strong>Harness result:</strong> <span class="badge bg-secondary">@_testResult.Outcome</span>
|
||||
@if (_testResult.Outcome == ScriptTestOutcome.Success)
|
||||
{
|
||||
<div>Output: <code>@(_testResult.Output?.ToString() ?? "null")</code></div>
|
||||
@if (_testResult.Writes.Count > 0)
|
||||
{
|
||||
<div class="mt-1"><strong>Writes:</strong>
|
||||
<ul class="mb-0">
|
||||
@foreach (var kv in _testResult.Writes) { <li><code>@kv.Key</code> = <code>@(kv.Value?.ToString() ?? "null")</code></li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (_testResult.Errors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning mt-2 mb-0">
|
||||
@foreach (var e in _testResult.Errors) { <div>@e</div> }
|
||||
</div>
|
||||
}
|
||||
@if (_testResult.LogEvents.Count > 0)
|
||||
{
|
||||
<div class="mt-2"><strong>Script log output:</strong>
|
||||
<ul class="small mb-0">
|
||||
@foreach (var e in _testResult.LogEvents) { <li>[@e.Level] @e.RenderMessage()</li> }
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public long GenerationId { get; set; }
|
||||
[Parameter] public string ClusterId { get; set; } = string.Empty;
|
||||
|
||||
private bool _loading = true;
|
||||
private bool _busy;
|
||||
private bool _harnessBusy;
|
||||
private bool _isNew;
|
||||
private List<Script> _scripts = [];
|
||||
private Script? _editing;
|
||||
private DependencyExtractionResult? _dependencies;
|
||||
private ScriptTestResult? _testResult;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void Open(Script s)
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
ScriptRowId = s.ScriptRowId, GenerationId = s.GenerationId,
|
||||
ScriptId = s.ScriptId, Name = s.Name, SourceCode = s.SourceCode,
|
||||
SourceHash = s.SourceHash, Language = s.Language,
|
||||
};
|
||||
_isNew = false;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private void StartNew()
|
||||
{
|
||||
_editing = new Script
|
||||
{
|
||||
GenerationId = GenerationId, ScriptId = "",
|
||||
Name = "new-script", SourceCode = "return 0;", SourceHash = "",
|
||||
};
|
||||
_isNew = true;
|
||||
_dependencies = null;
|
||||
_testResult = null;
|
||||
}
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_busy = true;
|
||||
try
|
||||
{
|
||||
if (_isNew)
|
||||
await ScriptSvc.AddAsync(GenerationId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
else
|
||||
await ScriptSvc.UpdateAsync(GenerationId, _editing.ScriptId, _editing.Name, _editing.SourceCode, CancellationToken.None);
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
_isNew = false;
|
||||
}
|
||||
finally { _busy = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (_editing is null || _isNew) return;
|
||||
await ScriptSvc.DeleteAsync(GenerationId, _editing.ScriptId, CancellationToken.None);
|
||||
_editing = null;
|
||||
_scripts = await ScriptSvc.ListAsync(GenerationId, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void PreviewDependencies()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_dependencies = DependencyExtractor.Extract(_editing.SourceCode);
|
||||
}
|
||||
|
||||
private async Task RunHarnessAsync()
|
||||
{
|
||||
if (_editing is null) return;
|
||||
_harnessBusy = true;
|
||||
try
|
||||
{
|
||||
_dependencies ??= DependencyExtractor.Extract(_editing.SourceCode);
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>();
|
||||
foreach (var read in _dependencies.Reads)
|
||||
inputs[read] = new DataValueSnapshot(0.0, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
_testResult = await Harness.RunVirtualTagAsync(_editing.SourceCode, inputs, CancellationToken.None);
|
||||
}
|
||||
finally { _harnessBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,18 @@ builder.Services.AddScoped<EquipmentImportBatchService>();
|
||||
builder.Services.AddScoped<ZB.MOM.WW.OtOpcUa.Configuration.Services.ILdapGroupRoleMappingService,
|
||||
ZB.MOM.WW.OtOpcUa.Configuration.Services.LdapGroupRoleMappingService>();
|
||||
|
||||
// Phase 7 Stream F — scripting + virtual tag + scripted alarm draft services, test
|
||||
// harness, and historian diagnostics. The historian sink is the Null variant here —
|
||||
// the real SqliteStoreAndForwardSink lives in the server process. Admin reads status
|
||||
// from whichever sink is provided at composition time.
|
||||
builder.Services.AddScoped<ScriptService>();
|
||||
builder.Services.AddScoped<VirtualTagService>();
|
||||
builder.Services.AddScoped<ScriptedAlarmService>();
|
||||
builder.Services.AddScoped<ScriptTestHarnessService>();
|
||||
builder.Services.AddScoped<HistorianDiagnosticsService>();
|
||||
builder.Services.AddSingleton<ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.IAlarmHistorianSink>(
|
||||
ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.NullAlarmHistorianSink.Instance);
|
||||
|
||||
// Cert-trust management — reads the OPC UA server's PKI store root so rejected client certs
|
||||
// can be promoted to trusted via the Admin UI. Singleton: no per-request state, just
|
||||
// filesystem operations.
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Surfaces the local-node historian queue health on the Admin UI's
|
||||
/// <c>/alarms/historian</c> diagnostics page (Phase 7 plan decisions #16/#21).
|
||||
/// Exposes queue depth / drain state / last-error, and lets the operator retry
|
||||
/// dead-lettered rows without restarting the node.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sink injected here is the server-process <see cref="IAlarmHistorianSink"/>.
|
||||
/// When <see cref="NullAlarmHistorianSink"/> is bound (historian disabled for this
|
||||
/// deployment), <see cref="TryRetryDeadLettered"/> silently returns 0 and
|
||||
/// <see cref="GetStatus"/> reports <see cref="HistorianDrainState.Disabled"/>.
|
||||
/// </remarks>
|
||||
public sealed class HistorianDiagnosticsService(IAlarmHistorianSink sink)
|
||||
{
|
||||
public HistorianSinkStatus GetStatus() => sink.GetStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Operator action from the UI's "Retry dead-lettered" button. Returns the number
|
||||
/// of rows revived so the UI can flash a confirmation. When the live sink doesn't
|
||||
/// implement retry (test doubles, Null sink), returns 0.
|
||||
/// </summary>
|
||||
public int TryRetryDeadLettered()
|
||||
{
|
||||
if (sink is SqliteStoreAndForwardSink concrete)
|
||||
return concrete.RetryDeadLettered();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
66
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptService.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draft-generation CRUD for <see cref="Script"/> rows — the C# source code referenced
|
||||
/// by Phase 7 virtual tags and scripted alarms. <see cref="Script.SourceHash"/> is
|
||||
/// recomputed on every save so Core.Scripting's compile cache sees a fresh key when
|
||||
/// source changes and reuses the compile when it doesn't.
|
||||
/// </summary>
|
||||
public sealed class ScriptService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<Script>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.Where(s => s.GenerationId == generationId)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<Script?> GetAsync(long generationId, string scriptId, CancellationToken ct) =>
|
||||
db.Scripts.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.GenerationId == generationId && s.ScriptId == scriptId, ct);
|
||||
|
||||
public async Task<Script> AddAsync(long generationId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = new Script
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptId = $"scr-{Guid.NewGuid():N}"[..20],
|
||||
Name = name,
|
||||
SourceCode = sourceCode,
|
||||
SourceHash = ComputeHash(sourceCode),
|
||||
};
|
||||
db.Scripts.Add(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task<Script> UpdateAsync(long generationId, string scriptId, string name, string sourceCode, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct)
|
||||
?? throw new InvalidOperationException($"Script '{scriptId}' not found in generation {generationId}");
|
||||
s.Name = name;
|
||||
s.SourceCode = sourceCode;
|
||||
s.SourceHash = ComputeHash(sourceCode);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return s;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptId, CancellationToken ct)
|
||||
{
|
||||
var s = await db.Scripts.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptId == scriptId, ct);
|
||||
if (s is null) return;
|
||||
db.Scripts.Remove(s);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
internal static string ComputeHash(string source)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(source ?? string.Empty));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
121
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptTestHarnessService.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using Serilog; // resolves Serilog.ILogger explicitly in signatures
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dry-run harness for the Phase 7 scripting UI. Takes a script + a synthetic input
|
||||
/// map + evaluates once, returns the output (or rejection / exception) plus any
|
||||
/// logger emissions the script produced. Per Phase 7 plan decision #22: only inputs
|
||||
/// the <see cref="DependencyExtractor"/> identified can be supplied, so a dependency
|
||||
/// the harness can't prove statically surfaces as a harness error, not a runtime
|
||||
/// surprise later.
|
||||
/// </summary>
|
||||
public sealed class ScriptTestHarnessService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="source"/> as a virtual-tag script (return value is the
|
||||
/// tag's new value). <paramref name="inputs"/> supplies synthetic
|
||||
/// <see cref="DataValueSnapshot"/>s for every path the extractor found.
|
||||
/// </summary>
|
||||
public async Task<ScriptTestResult> RunVirtualTagAsync(
|
||||
string source, IDictionary<string, DataValueSnapshot> inputs, CancellationToken ct)
|
||||
{
|
||||
var deps = DependencyExtractor.Extract(source);
|
||||
if (!deps.IsValid)
|
||||
return ScriptTestResult.DependencyRejections(deps.Rejections);
|
||||
|
||||
var missing = deps.Reads.Where(r => !inputs.ContainsKey(r)).ToArray();
|
||||
if (missing.Length > 0)
|
||||
return ScriptTestResult.MissingInputs(missing);
|
||||
|
||||
var extra = inputs.Keys.Where(k => !deps.Reads.Contains(k)).ToArray();
|
||||
if (extra.Length > 0)
|
||||
return ScriptTestResult.UnknownInputs(extra);
|
||||
|
||||
ScriptEvaluator<HarnessVirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = ScriptEvaluator<HarnessVirtualTagContext, object?>.Compile(source);
|
||||
}
|
||||
catch (Exception compileEx)
|
||||
{
|
||||
return ScriptTestResult.Threw(compileEx.Message, []);
|
||||
}
|
||||
var capturing = new CapturingSink();
|
||||
var logger = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(capturing).CreateLogger();
|
||||
var ctx = new HarnessVirtualTagContext(inputs, logger);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await evaluator.RunAsync(ctx, ct);
|
||||
return ScriptTestResult.Ok(result, ctx.Writes, capturing.Events);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ScriptTestResult.Threw(ex.Message, capturing.Events);
|
||||
}
|
||||
}
|
||||
|
||||
// Public so Roslyn's script compilation can reference the context type through the
|
||||
// ScriptGlobals<T> surface. The harness instantiates this directly; operators never see it.
|
||||
public sealed class HarnessVirtualTagContext(
|
||||
IDictionary<string, DataValueSnapshot> inputs, Serilog.ILogger logger) : ScriptContext
|
||||
{
|
||||
public Dictionary<string, object?> Writes { get; } = [];
|
||||
public override DataValueSnapshot GetTag(string path) =>
|
||||
inputs.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, Ua.StatusCodes.BadNotFound, null, DateTime.UtcNow);
|
||||
public override void SetVirtualTag(string path, object? value) => Writes[path] = value;
|
||||
public override DateTime Now => DateTime.UtcNow;
|
||||
public override Serilog.ILogger Logger => logger;
|
||||
}
|
||||
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent e) => Events.Add(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Harness outcome: outputs, write-set, logger events, or a rejection/throw reason.</summary>
|
||||
public sealed record ScriptTestResult(
|
||||
ScriptTestOutcome Outcome,
|
||||
object? Output,
|
||||
IReadOnlyDictionary<string, object?> Writes,
|
||||
IReadOnlyList<LogEvent> LogEvents,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static ScriptTestResult Ok(object? output, IReadOnlyDictionary<string, object?> writes, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Success, output, writes, logs, []);
|
||||
public static ScriptTestResult Threw(string reason, IReadOnlyList<LogEvent> logs) =>
|
||||
new(ScriptTestOutcome.Threw, null, new Dictionary<string, object?>(), logs, [reason]);
|
||||
public static ScriptTestResult DependencyRejections(IReadOnlyList<DependencyRejection> rejs) =>
|
||||
new(ScriptTestOutcome.DependencyRejected, null, new Dictionary<string, object?>(), [],
|
||||
rejs.Select(r => r.Message).ToArray());
|
||||
public static ScriptTestResult MissingInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.MissingInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Missing synthetic input: {p}").ToArray());
|
||||
public static ScriptTestResult UnknownInputs(string[] paths) =>
|
||||
new(ScriptTestOutcome.UnknownInputs, null, new Dictionary<string, object?>(), [],
|
||||
paths.Select(p => $"Input '{p}' is not referenced by the script — remove it").ToArray());
|
||||
}
|
||||
|
||||
public enum ScriptTestOutcome
|
||||
{
|
||||
Success,
|
||||
Threw,
|
||||
DependencyRejected,
|
||||
MissingInputs,
|
||||
UnknownInputs,
|
||||
}
|
||||
|
||||
file static class Ua
|
||||
{
|
||||
// Mirrors OPC UA StatusCodes.BadNotFound without pulling the OPC stack into Admin.
|
||||
public static class StatusCodes { public const uint BadNotFound = 0x803E0000; }
|
||||
}
|
||||
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
55
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ScriptedAlarmService.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="ScriptedAlarm"/> rows.</summary>
|
||||
public sealed class ScriptedAlarmService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ScriptedAlarm>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.ScriptedAlarms.AsNoTracking()
|
||||
.Where(a => a.GenerationId == generationId)
|
||||
.OrderBy(a => a.EquipmentId).ThenBy(a => a.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<ScriptedAlarm> AddAsync(
|
||||
long generationId, string equipmentId, string name, string alarmType,
|
||||
int severity, string messageTemplate, string predicateScriptId,
|
||||
bool historizeToAveva, bool retain, CancellationToken ct)
|
||||
{
|
||||
var a = new ScriptedAlarm
|
||||
{
|
||||
GenerationId = generationId,
|
||||
ScriptedAlarmId = $"sal-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
AlarmType = alarmType,
|
||||
Severity = severity,
|
||||
MessageTemplate = messageTemplate,
|
||||
PredicateScriptId = predicateScriptId,
|
||||
HistorizeToAveva = historizeToAveva,
|
||||
Retain = retain,
|
||||
};
|
||||
db.ScriptedAlarms.Add(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return a;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string scriptedAlarmId, CancellationToken ct)
|
||||
{
|
||||
var a = await db.ScriptedAlarms.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
if (a is null) return;
|
||||
db.ScriptedAlarms.Remove(a);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the persistent state row (ack/confirm/shelve) for this alarm identity —
|
||||
/// alarm state is NOT generation-scoped per Phase 7 plan decision #14, so the
|
||||
/// lookup is by <see cref="ScriptedAlarm.ScriptedAlarmId"/> only.
|
||||
/// </summary>
|
||||
public Task<ScriptedAlarmState?> GetStateAsync(string scriptedAlarmId, CancellationToken ct) =>
|
||||
db.ScriptedAlarmStates.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.ScriptedAlarmId == scriptedAlarmId, ct);
|
||||
}
|
||||
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Admin/Services/VirtualTagService.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>Draft-generation CRUD for <see cref="VirtualTag"/> rows.</summary>
|
||||
public sealed class VirtualTagService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<VirtualTag>> ListAsync(long generationId, CancellationToken ct) =>
|
||||
db.VirtualTags.AsNoTracking()
|
||||
.Where(v => v.GenerationId == generationId)
|
||||
.OrderBy(v => v.EquipmentId).ThenBy(v => v.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<VirtualTag> AddAsync(
|
||||
long generationId, string equipmentId, string name, string dataType, string scriptId,
|
||||
bool changeTriggered, int? timerIntervalMs, bool historize, CancellationToken ct)
|
||||
{
|
||||
var v = new VirtualTag
|
||||
{
|
||||
GenerationId = generationId,
|
||||
VirtualTagId = $"vt-{Guid.NewGuid():N}"[..20],
|
||||
EquipmentId = equipmentId,
|
||||
Name = name,
|
||||
DataType = dataType,
|
||||
ScriptId = scriptId,
|
||||
ChangeTriggered = changeTriggered,
|
||||
TimerIntervalMs = timerIntervalMs,
|
||||
Historize = historize,
|
||||
};
|
||||
db.VirtualTags.Add(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(long generationId, string virtualTagId, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct);
|
||||
if (v is null) return;
|
||||
db.VirtualTags.Remove(v);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<VirtualTag> UpdateEnabledAsync(long generationId, string virtualTagId, bool enabled, CancellationToken ct)
|
||||
{
|
||||
var v = await db.VirtualTags.FirstOrDefaultAsync(x => x.GenerationId == generationId && x.VirtualTagId == virtualTagId, ct)
|
||||
?? throw new InvalidOperationException($"VirtualTag '{virtualTagId}' not found in generation {generationId}");
|
||||
v.Enabled = enabled;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
59
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/js/monaco-loader.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// Phase 7 Stream F — Monaco editor loader for ScriptEditor.razor.
|
||||
// Progressive enhancement: the textarea is authoritative until Monaco attaches;
|
||||
// after attach, Monaco syncs back into the textarea on every change so Blazor's
|
||||
// @bind still sees the latest value.
|
||||
|
||||
(function () {
|
||||
if (window.otOpcUaScriptEditor) return;
|
||||
|
||||
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/min/vs';
|
||||
let loaderPromise = null;
|
||||
|
||||
function ensureLoader() {
|
||||
if (loaderPromise) return loaderPromise;
|
||||
loaderPromise = new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `${MONACO_CDN}/loader.js`;
|
||||
script.onload = () => {
|
||||
window.require.config({ paths: { vs: MONACO_CDN } });
|
||||
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
|
||||
};
|
||||
script.onerror = () => reject(new Error('Monaco CDN unreachable'));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return loaderPromise;
|
||||
}
|
||||
|
||||
window.otOpcUaScriptEditor = {
|
||||
attach: async function (textareaId) {
|
||||
const ta = document.getElementById(textareaId);
|
||||
if (!ta) return;
|
||||
const monaco = await ensureLoader();
|
||||
|
||||
// Mount Monaco over the textarea. The textarea stays in the DOM as the
|
||||
// source of truth for Blazor's @bind — Monaco mirrors into it on every
|
||||
// keystroke so server-side state stays in sync.
|
||||
const host = document.createElement('div');
|
||||
host.style.height = '340px';
|
||||
host.style.border = '1px solid #ced4da';
|
||||
host.style.borderRadius = '0.25rem';
|
||||
ta.style.display = 'none';
|
||||
ta.parentNode.insertBefore(host, ta);
|
||||
|
||||
const editor = monaco.editor.create(host, {
|
||||
value: ta.value,
|
||||
language: 'csharp',
|
||||
theme: 'vs',
|
||||
automaticLayout: true,
|
||||
fontSize: 13,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(() => {
|
||||
ta.value = editor.getValue();
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
},
|
||||
};
|
||||
})();
|
||||
38
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs
Normal file
38
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #8 — user-authored C# script source, referenced by
|
||||
/// <see cref="VirtualTag"/> and <see cref="ScriptedAlarm"/>. One row per script,
|
||||
/// per generation. <c>SourceHash</c> is the compile-cache key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Scripts are generation-scoped: a draft's edit creates a new row in the draft
|
||||
/// generation, the old row stays frozen in the published generation. Shape mirrors
|
||||
/// the other generation-scoped entities (Equipment, Tag, etc.) — <c>ScriptId</c> is
|
||||
/// the stable logical id that carries across generations; <c>ScriptRowId</c> is the
|
||||
/// row identity.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Script
|
||||
{
|
||||
public Guid ScriptRowId { get; set; }
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id. Carries across generations.</summary>
|
||||
public required string ScriptId { get; set; }
|
||||
|
||||
/// <summary>Operator-friendly name for log filtering + Admin UI list view.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Raw C# source. Size bounded by the DB column (nvarchar(max)).</summary>
|
||||
public required string SourceCode { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of <see cref="SourceCode"/> — compile-cache key for Phase 7 Stream A's <c>CompiledScriptCache</c>.</summary>
|
||||
public required string SourceHash { get; set; }
|
||||
|
||||
/// <summary>Language — always "CSharp" today; placeholder for future engines (Python/Lua).</summary>
|
||||
public string Language { get; set; } = "CSharp";
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decisions #5, #13, #15 — a scripted OPC UA Part 9 alarm whose
|
||||
/// condition is the predicate <see cref="Script"/> referenced by
|
||||
/// <see cref="PredicateScriptId"/>. Materialized by <c>Core.ScriptedAlarms</c> as a
|
||||
/// concrete <c>AlarmConditionType</c> subtype per <see cref="AlarmType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Message tokens (<c>{TagPath}</c>) resolved at emission time per plan decision #13.
|
||||
/// <see cref="HistorizeToAveva"/> (plan decision #15) gates whether transitions
|
||||
/// route through the Core.AlarmHistorian SQLite queue + Galaxy.Host to the Aveva
|
||||
/// Historian alarm schema.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarm
|
||||
{
|
||||
public Guid ScriptedAlarmRowId { get; set; }
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id — drives <c>AlarmConditionType.ConditionName</c>.</summary>
|
||||
public required string ScriptedAlarmId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this alarm.</summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Operator-facing alarm name.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Concrete Part 9 type — "AlarmCondition" / "LimitAlarm" / "OffNormalAlarm" / "DiscreteAlarm".</summary>
|
||||
public required string AlarmType { get; set; }
|
||||
|
||||
/// <summary>Numeric severity 1..1000 per OPC UA Part 9 (usual bands: 1-250 Low, 251-500 Medium, 501-750 High, 751-1000 Critical).</summary>
|
||||
public int Severity { get; set; } = 500;
|
||||
|
||||
/// <summary>Template with <c>{TagPath}</c> tokens resolved at emission time.</summary>
|
||||
public required string MessageTemplate { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — predicate script returning <c>bool</c>.</summary>
|
||||
public required string PredicateScriptId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan decision #15 — when true, transitions route through the SQLite store-and-forward
|
||||
/// queue to the Aveva Historian. Defaults on for scripted alarms because they are the
|
||||
/// primary motivation for the historian sink; operator can disable per alarm.
|
||||
/// </summary>
|
||||
public bool HistorizeToAveva { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Part 9 <c>Retain</c> flag — whether the alarm keeps active-state between
|
||||
/// sessions. Most plant alarms are retained; one-shot event-style alarms are not.
|
||||
/// </summary>
|
||||
public bool Retain { get; set; } = true;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #14 — persistent runtime state for each scripted alarm.
|
||||
/// Survives process restart so operators don't re-ack and ack history survives for
|
||||
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
|
||||
/// per-generation) because ack state follows the alarm's stable identity across
|
||||
/// generations — a Modified alarm keeps its ack history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>ActiveState</c> is deliberately NOT persisted — it rederives from the current
|
||||
/// predicate evaluation on startup. Only operator-supplied state (<see cref="AckedState"/>,
|
||||
/// <see cref="ConfirmedState"/>, <see cref="ShelvingState"/>) + audit trail persist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="CommentsJson"/> is an append-only JSON array of <c>{user, utc, text}</c>
|
||||
/// tuples — one per operator comment. Core.ScriptedAlarms' <c>AlarmConditionState.Comments</c>
|
||||
/// serializes directly into this column.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarmState
|
||||
{
|
||||
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
|
||||
public required string ScriptedAlarmId { get; set; }
|
||||
|
||||
/// <summary>Enabled/Disabled. Persists across restart per plan decision #14.</summary>
|
||||
public required string EnabledState { get; set; } = "Enabled";
|
||||
|
||||
/// <summary>Unacknowledged / Acknowledged.</summary>
|
||||
public required string AckedState { get; set; } = "Unacknowledged";
|
||||
|
||||
/// <summary>Unconfirmed / Confirmed.</summary>
|
||||
public required string ConfirmedState { get; set; } = "Unconfirmed";
|
||||
|
||||
/// <summary>Unshelved / OneShotShelved / TimedShelved.</summary>
|
||||
public required string ShelvingState { get; set; } = "Unshelved";
|
||||
|
||||
/// <summary>When a TimedShelve expires — null if not shelved or OneShotShelved.</summary>
|
||||
public DateTime? ShelvingExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>User who last acknowledged. Null if never acked.</summary>
|
||||
public string? LastAckUser { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
|
||||
public string? LastAckComment { get; set; }
|
||||
|
||||
public DateTime? LastAckUtc { get; set; }
|
||||
|
||||
/// <summary>User who last confirmed.</summary>
|
||||
public string? LastConfirmUser { get; set; }
|
||||
|
||||
public string? LastConfirmComment { get; set; }
|
||||
|
||||
public DateTime? LastConfirmUtc { get; set; }
|
||||
|
||||
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
|
||||
public string CommentsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>Row write timestamp — tracks last state change.</summary>
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
}
|
||||
53
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs
Normal file
53
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/VirtualTag.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #2 — a virtual (calculated) tag that lives in the
|
||||
/// Equipment tree alongside driver tags. Value is produced by the
|
||||
/// <see cref="Script"/> referenced by <see cref="ScriptId"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="EquipmentId"/> is mandatory — virtual tags are always scoped to an
|
||||
/// Equipment node per plan decision #2 (unified Equipment tree, not a separate
|
||||
/// /Virtual namespace). <see cref="DataType"/> matches the shape used by
|
||||
/// <c>Tag.DataType</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ChangeTriggered"/> and <see cref="TimerIntervalMs"/> together realize
|
||||
/// plan decision #3 (change + timer). At least one must produce evaluations; the
|
||||
/// Core.VirtualTags engine rejects an all-disabled tag at load time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VirtualTag
|
||||
{
|
||||
public Guid VirtualTagRowId { get; set; }
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id.</summary>
|
||||
public required string VirtualTagId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this virtual tag.</summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Browse name — unique within owning Equipment.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>DataType string — same vocabulary as <see cref="Tag.DataType"/>.</summary>
|
||||
public required string DataType { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — the script that computes this tag's value.</summary>
|
||||
public required string ScriptId { get; set; }
|
||||
|
||||
/// <summary>Re-evaluate when any referenced input tag changes. Default on.</summary>
|
||||
public bool ChangeTriggered { get; set; } = true;
|
||||
|
||||
/// <summary>Timer re-evaluation cadence in milliseconds. <c>null</c> = no timer.</summary>
|
||||
public int? TimerIntervalMs { get; set; }
|
||||
|
||||
/// <summary>Per plan decision #10 — checkbox to route this tag's values through <c>IHistoryWriter</c>.</summary>
|
||||
public bool Historize { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
1793
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.Designer.cs
generated
Normal file
1793
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPhase7ScriptingTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Script",
|
||||
columns: table => new
|
||||
{
|
||||
ScriptRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
SourceCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SourceHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Language = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Script", x => x.ScriptRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Script_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ScriptedAlarm",
|
||||
columns: table => new
|
||||
{
|
||||
ScriptedAlarmRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
AlarmType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Severity = table.Column<int>(type: "int", nullable: false),
|
||||
MessageTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
|
||||
PredicateScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
HistorizeToAveva = table.Column<bool>(type: "bit", nullable: false),
|
||||
Retain = table.Column<bool>(type: "bit", nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ScriptedAlarm", x => x.ScriptedAlarmRowId);
|
||||
table.CheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
|
||||
table.CheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
|
||||
table.ForeignKey(
|
||||
name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ScriptedAlarmState",
|
||||
columns: table => new
|
||||
{
|
||||
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
|
||||
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VirtualTag",
|
||||
columns: table => new
|
||||
{
|
||||
VirtualTagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
VirtualTagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ChangeTriggered = table.Column<bool>(type: "bit", nullable: false),
|
||||
TimerIntervalMs = table.Column<int>(type: "int", nullable: true),
|
||||
Historize = table.Column<bool>(type: "bit", nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VirtualTag", x => x.VirtualTagRowId);
|
||||
table.CheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
|
||||
table.CheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
|
||||
table.ForeignKey(
|
||||
name: "FK_VirtualTag_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Script_Generation_SourceHash",
|
||||
table: "Script",
|
||||
columns: new[] { "GenerationId", "SourceHash" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Script_Generation_LogicalId",
|
||||
table: "Script",
|
||||
columns: new[] { "GenerationId", "ScriptId" },
|
||||
unique: true,
|
||||
filter: "[ScriptId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ScriptedAlarm_Generation_Script",
|
||||
table: "ScriptedAlarm",
|
||||
columns: new[] { "GenerationId", "PredicateScriptId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ScriptedAlarm_Generation_EquipmentPath",
|
||||
table: "ScriptedAlarm",
|
||||
columns: new[] { "GenerationId", "EquipmentId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ScriptedAlarm_Generation_LogicalId",
|
||||
table: "ScriptedAlarm",
|
||||
columns: new[] { "GenerationId", "ScriptedAlarmId" },
|
||||
unique: true,
|
||||
filter: "[ScriptedAlarmId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VirtualTag_Generation_Script",
|
||||
table: "VirtualTag",
|
||||
columns: new[] { "GenerationId", "ScriptId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_VirtualTag_Generation_EquipmentPath",
|
||||
table: "VirtualTag",
|
||||
columns: new[] { "GenerationId", "EquipmentId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_VirtualTag_Generation_LogicalId",
|
||||
table: "VirtualTag",
|
||||
columns: new[] { "GenerationId", "VirtualTagId" },
|
||||
unique: true,
|
||||
filter: "[VirtualTagId] IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Script");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ScriptedAlarm");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ScriptedAlarmState");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VirtualTag");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1027,6 +1027,193 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
|
||||
{
|
||||
b.Property<Guid>("ScriptRowId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
|
||||
b.Property<long>("GenerationId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ScriptId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("SourceCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SourceHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("ScriptRowId");
|
||||
|
||||
b.HasIndex("GenerationId", "ScriptId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_Script_Generation_LogicalId")
|
||||
.HasFilter("[ScriptId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("GenerationId", "SourceHash")
|
||||
.HasDatabaseName("IX_Script_Generation_SourceHash");
|
||||
|
||||
b.ToTable("Script", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b =>
|
||||
{
|
||||
b.Property<Guid>("ScriptedAlarmRowId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
|
||||
b.Property<string>("AlarmType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("EquipmentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<long>("GenerationId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("HistorizeToAveva")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("MessageTemplate")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("PredicateScriptId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<bool>("Retain")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("ScriptedAlarmId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int>("Severity")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("ScriptedAlarmRowId");
|
||||
|
||||
b.HasIndex("GenerationId", "PredicateScriptId")
|
||||
.HasDatabaseName("IX_ScriptedAlarm_Generation_Script");
|
||||
|
||||
b.HasIndex("GenerationId", "ScriptedAlarmId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId")
|
||||
.HasFilter("[ScriptedAlarmId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("GenerationId", "EquipmentId", "Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath");
|
||||
|
||||
b.ToTable("ScriptedAlarm", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
|
||||
|
||||
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b =>
|
||||
{
|
||||
b.Property<string>("ScriptedAlarmId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("AckedState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("CommentsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ConfirmedState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("EnabledState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<string>("LastAckComment")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("LastAckUser")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<DateTime?>("LastAckUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<string>("LastConfirmComment")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("nvarchar(1024)");
|
||||
|
||||
b.Property<string>("LastConfirmUser")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<DateTime?>("LastConfirmUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<DateTime?>("ShelvingExpiresUtc")
|
||||
.HasColumnType("datetime2(3)");
|
||||
|
||||
b.Property<string>("ShelvingState")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("nvarchar(16)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAtUtc")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2(3)")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.HasKey("ScriptedAlarmId");
|
||||
|
||||
b.ToTable("ScriptedAlarmState", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
|
||||
{
|
||||
b.Property<string>("ClusterId")
|
||||
@@ -1274,6 +1461,74 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.ToTable("UnsLine", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
|
||||
{
|
||||
b.Property<Guid>("VirtualTagRowId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uniqueidentifier")
|
||||
.HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
|
||||
b.Property<bool>("ChangeTriggered")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("DataType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("EquipmentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<long>("GenerationId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("Historize")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ScriptId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<int?>("TimerIntervalMs")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("VirtualTagId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.HasKey("VirtualTagRowId");
|
||||
|
||||
b.HasIndex("GenerationId", "ScriptId")
|
||||
.HasDatabaseName("IX_VirtualTag_Generation_Script");
|
||||
|
||||
b.HasIndex("GenerationId", "VirtualTagId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_VirtualTag_Generation_LogicalId")
|
||||
.HasFilter("[VirtualTagId] IS NOT NULL");
|
||||
|
||||
b.HasIndex("GenerationId", "EquipmentId", "Name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath");
|
||||
|
||||
b.ToTable("VirtualTag", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
|
||||
|
||||
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
|
||||
@@ -1435,6 +1690,28 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.Navigation("Generation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
|
||||
.WithMany()
|
||||
.HasForeignKey("GenerationId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Generation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
|
||||
.WithMany()
|
||||
.HasForeignKey("GenerationId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Generation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
|
||||
@@ -1476,6 +1753,17 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
b.Navigation("Generation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b =>
|
||||
{
|
||||
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation")
|
||||
.WithMany()
|
||||
.HasForeignKey("GenerationId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Generation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b =>
|
||||
{
|
||||
b.Navigation("Credentials");
|
||||
|
||||
@@ -32,6 +32,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
|
||||
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
|
||||
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
|
||||
public DbSet<Script> Scripts => Set<Script>();
|
||||
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
|
||||
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
|
||||
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -56,6 +60,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
ConfigureDriverInstanceResilienceStatus(modelBuilder);
|
||||
ConfigureLdapGroupRoleMapping(modelBuilder);
|
||||
ConfigureEquipmentImportBatch(modelBuilder);
|
||||
ConfigureScript(modelBuilder);
|
||||
ConfigureVirtualTag(modelBuilder);
|
||||
ConfigureScriptedAlarm(modelBuilder);
|
||||
ConfigureScriptedAlarmState(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||
@@ -619,4 +627,106 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
|
||||
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureScript(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Script>(e =>
|
||||
{
|
||||
e.ToTable("Script");
|
||||
e.HasKey(x => x.ScriptRowId);
|
||||
e.Property(x => x.ScriptRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.ScriptId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.SourceCode).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.SourceHash).HasMaxLength(64);
|
||||
e.Property(x => x.Language).HasMaxLength(16);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).IsUnique().HasDatabaseName("UX_Script_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.SourceHash }).HasDatabaseName("IX_Script_Generation_SourceHash");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureVirtualTag(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<VirtualTag>(e =>
|
||||
{
|
||||
e.ToTable("VirtualTag", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne",
|
||||
"ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
|
||||
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min",
|
||||
"TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
|
||||
});
|
||||
e.HasKey(x => x.VirtualTagRowId);
|
||||
e.Property(x => x.VirtualTagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.VirtualTagId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.DataType).HasMaxLength(32);
|
||||
e.Property(x => x.ScriptId).HasMaxLength(64);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.VirtualTagId }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).HasDatabaseName("IX_VirtualTag_Generation_Script");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureScriptedAlarm(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ScriptedAlarm>(e =>
|
||||
{
|
||||
e.ToTable("ScriptedAlarm", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
|
||||
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType",
|
||||
"AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
|
||||
});
|
||||
e.HasKey(x => x.ScriptedAlarmRowId);
|
||||
e.Property(x => x.ScriptedAlarmRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.AlarmType).HasMaxLength(32);
|
||||
e.Property(x => x.MessageTemplate).HasMaxLength(1024);
|
||||
e.Property(x => x.PredicateScriptId).HasMaxLength(64);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScriptedAlarmId }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.PredicateScriptId }).HasDatabaseName("IX_ScriptedAlarm_Generation_Script");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureScriptedAlarmState(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ScriptedAlarmState>(e =>
|
||||
{
|
||||
// Logical-id keyed (not generation-scoped) because ack state follows the alarm's
|
||||
// stable identity across generations — Modified alarms keep their ack audit trail.
|
||||
e.ToTable("ScriptedAlarmState", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
e.HasKey(x => x.ScriptedAlarmId);
|
||||
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
|
||||
e.Property(x => x.EnabledState).HasMaxLength(16);
|
||||
e.Property(x => x.AckedState).HasMaxLength(16);
|
||||
e.Property(x => x.ConfirmedState).HasMaxLength(16);
|
||||
e.Property(x => x.ShelvingState).HasMaxLength(16);
|
||||
e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastAckUser).HasMaxLength(128);
|
||||
e.Property(x => x.LastAckComment).HasMaxLength(1024);
|
||||
e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastConfirmUser).HasMaxLength(128);
|
||||
e.Property(x => x.LastConfirmComment).HasMaxLength(1024);
|
||||
e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,18 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
||||
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
||||
/// </param>
|
||||
/// <param name="Source">
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
|
||||
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
|
||||
/// </param>
|
||||
/// <param name="VirtualTagId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
|
||||
/// logical id the VirtualTagEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
/// <param name="ScriptedAlarmId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
public sealed record DriverAttributeInfo(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
@@ -41,4 +53,21 @@ public sealed record DriverAttributeInfo(
|
||||
SecurityClassification SecurityClass,
|
||||
bool IsHistorized,
|
||||
bool IsAlarm = false,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||
string? VirtualTagId = null,
|
||||
string? ScriptedAlarmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
|
||||
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
|
||||
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
|
||||
/// materialized by the ScriptedAlarmEngine.
|
||||
/// </summary>
|
||||
public enum NodeSourceKind
|
||||
{
|
||||
Driver = 0,
|
||||
Virtual = 1,
|
||||
ScriptedAlarm = 2,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// The event shape the historian sink consumes — source-agnostic across scripted
|
||||
/// alarms + Galaxy-native + AB CIP ALMD + any future IAlarmSource per Phase 7 plan
|
||||
/// decision #15 (sink scope = all alarm sources, not just scripted). A per-alarm
|
||||
/// <c>HistorizeToAveva</c> toggle on the producer side gates which events flow.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">Stable condition identity.</param>
|
||||
/// <param name="EquipmentPath">UNS path of the Equipment node the alarm hangs under. Doubles as the "SourceNode" in Historian's alarm schema.</param>
|
||||
/// <param name="AlarmName">Human-readable alarm name.</param>
|
||||
/// <param name="AlarmTypeName">Concrete Part 9 subtype — "LimitAlarm" / "DiscreteAlarm" / "OffNormalAlarm" / "AlarmCondition". Used as the Historian "AlarmType" column.</param>
|
||||
/// <param name="Severity">Mapped to Historian's numeric priority on the sink side.</param>
|
||||
/// <param name="EventKind">
|
||||
/// Which state transition this event represents — "Activated" / "Cleared" /
|
||||
/// "Acknowledged" / "Confirmed" / "Shelved" / "Unshelved" / "Disabled" / "Enabled" /
|
||||
/// "CommentAdded". Free-form string because different alarm sources use different
|
||||
/// vocabularies; the Galaxy.Host handler maps to the historian's enum on the wire.
|
||||
/// </param>
|
||||
/// <param name="Message">Fully-rendered message text — template tokens already resolved upstream.</param>
|
||||
/// <param name="User">Operator who triggered the transition. "system" for engine-driven events (shelving expiry, predicate change).</param>
|
||||
/// <param name="Comment">Operator-supplied free-form text, if any.</param>
|
||||
/// <param name="TimestampUtc">When the transition occurred.</param>
|
||||
public sealed record AlarmHistorianEvent(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
string AlarmName,
|
||||
string AlarmTypeName,
|
||||
AlarmSeverity Severity,
|
||||
string EventKind,
|
||||
string Message,
|
||||
string User,
|
||||
string? Comment,
|
||||
DateTime TimestampUtc);
|
||||
@@ -0,0 +1,82 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// The historian sink contract — where qualifying alarm events land. Phase 7 plan
|
||||
/// decision #17: ingestion routes through Galaxy.Host's pipe so we reuse the
|
||||
/// already-loaded <c>aahClientManaged</c> DLLs without loading 32-bit native code
|
||||
/// in the main .NET 10 server. Tests use an in-memory fake; production uses
|
||||
/// <see cref="SqliteStoreAndForwardSink"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="EnqueueAsync"/> is fire-and-forget from the engine's perspective —
|
||||
/// the sink MUST NOT block the emitting thread. Production implementations
|
||||
/// (<see cref="SqliteStoreAndForwardSink"/>) persist to a local SQLite queue
|
||||
/// first, then drain asynchronously to the actual historian. Per Phase 7 plan
|
||||
/// decision #16, failed downstream writes replay with exponential backoff;
|
||||
/// operator actions are never blocked waiting on the historian.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="GetStatus"/> exposes queue depth + drain rate + last error
|
||||
/// for the Admin UI <c>/alarms/historian</c> diagnostics page (Stream F).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IAlarmHistorianSink
|
||||
{
|
||||
/// <summary>Durably enqueue the event. Returns as soon as the queue row is committed.</summary>
|
||||
Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Snapshot of current queue depth + drain health.</summary>
|
||||
HistorianSinkStatus GetStatus();
|
||||
}
|
||||
|
||||
/// <summary>No-op default for tests or deployments that don't historize alarms.</summary>
|
||||
public sealed class NullAlarmHistorianSink : IAlarmHistorianSink
|
||||
{
|
||||
public static readonly NullAlarmHistorianSink Instance = new();
|
||||
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public HistorianSinkStatus GetStatus() => new(
|
||||
QueueDepth: 0,
|
||||
DeadLetterDepth: 0,
|
||||
LastDrainUtc: null,
|
||||
LastSuccessUtc: null,
|
||||
LastError: null,
|
||||
DrainState: HistorianDrainState.Disabled);
|
||||
}
|
||||
|
||||
/// <summary>Diagnostic snapshot surfaced to the Admin UI + /healthz endpoints.</summary>
|
||||
public sealed record HistorianSinkStatus(
|
||||
long QueueDepth,
|
||||
long DeadLetterDepth,
|
||||
DateTime? LastDrainUtc,
|
||||
DateTime? LastSuccessUtc,
|
||||
string? LastError,
|
||||
HistorianDrainState DrainState);
|
||||
|
||||
/// <summary>Where the drain worker is in its state machine.</summary>
|
||||
public enum HistorianDrainState
|
||||
{
|
||||
Disabled,
|
||||
Idle,
|
||||
Draining,
|
||||
BackingOff,
|
||||
}
|
||||
|
||||
/// <summary>Signaled by the Galaxy.Host-side handler when it fails a batch — drain worker uses this to decide retry cadence.</summary>
|
||||
public enum HistorianWriteOutcome
|
||||
{
|
||||
/// <summary>Successfully persisted to the historian. Remove from queue.</summary>
|
||||
Ack,
|
||||
/// <summary>Transient failure (historian disconnected, timeout, busy). Leave queued; retry after backoff.</summary>
|
||||
RetryPlease,
|
||||
/// <summary>Permanent failure (malformed event, unrecoverable SDK error). Move to dead-letter table.</summary>
|
||||
PermanentFail,
|
||||
}
|
||||
|
||||
/// <summary>What the drain worker delegates writes to — Stream G wires this to the Galaxy.Host IPC client.</summary>
|
||||
public interface IAlarmHistorianWriter
|
||||
{
|
||||
/// <summary>Push a batch of events to the historian. Returns one outcome per event, same order.</summary>
|
||||
Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 plan decisions #16–#17 implementation: durable SQLite queue on the node
|
||||
/// absorbs every qualifying alarm event, a drain worker batches rows to Galaxy.Host
|
||||
/// via <see cref="IAlarmHistorianWriter"/> on an exponential-backoff cadence, and
|
||||
/// operator acks never block on the historian being reachable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Queue schema:
|
||||
/// <code>
|
||||
/// CREATE TABLE Queue (
|
||||
/// RowId INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
/// AlarmId TEXT NOT NULL,
|
||||
/// EnqueuedUtc TEXT NOT NULL,
|
||||
/// PayloadJson TEXT NOT NULL,
|
||||
/// AttemptCount INTEGER NOT NULL DEFAULT 0,
|
||||
/// LastAttemptUtc TEXT NULL,
|
||||
/// LastError TEXT NULL,
|
||||
/// DeadLettered INTEGER NOT NULL DEFAULT 0
|
||||
/// );
|
||||
/// </code>
|
||||
/// Dead-lettered rows stay in place for the configured retention window (default
|
||||
/// 30 days per Phase 7 plan decision #21) so operators can inspect + manually
|
||||
/// retry before the sweeper purges them. Regular queue capacity is bounded —
|
||||
/// overflow evicts the oldest non-dead-lettered rows with a WARN log.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Drain runs on a shared <see cref="System.Threading.Timer"/>. Exponential
|
||||
/// backoff on <see cref="HistorianWriteOutcome.RetryPlease"/>: 1s → 2s → 5s →
|
||||
/// 15s → 60s cap. <see cref="HistorianWriteOutcome.PermanentFail"/> rows flip
|
||||
/// the <c>DeadLettered</c> flag on the individual row; neighbors in the batch
|
||||
/// still retry on their own cadence.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SqliteStoreAndForwardSink : IAlarmHistorianSink, IDisposable
|
||||
{
|
||||
/// <summary>Default queue capacity — oldest non-dead-lettered rows evicted past this.</summary>
|
||||
public const long DefaultCapacity = 1_000_000;
|
||||
public static readonly TimeSpan DefaultDeadLetterRetention = TimeSpan.FromDays(30);
|
||||
|
||||
private static readonly TimeSpan[] BackoffLadder =
|
||||
[
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(15),
|
||||
TimeSpan.FromSeconds(60),
|
||||
];
|
||||
|
||||
private readonly string _connectionString;
|
||||
private readonly IAlarmHistorianWriter _writer;
|
||||
private readonly ILogger _logger;
|
||||
private readonly int _batchSize;
|
||||
private readonly long _capacity;
|
||||
private readonly TimeSpan _deadLetterRetention;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
private readonly SemaphoreSlim _drainGate = new(1, 1);
|
||||
private Timer? _drainTimer;
|
||||
private int _backoffIndex;
|
||||
private DateTime? _lastDrainUtc;
|
||||
private DateTime? _lastSuccessUtc;
|
||||
private string? _lastError;
|
||||
private HistorianDrainState _drainState = HistorianDrainState.Idle;
|
||||
private bool _disposed;
|
||||
|
||||
public SqliteStoreAndForwardSink(
|
||||
string databasePath,
|
||||
IAlarmHistorianWriter writer,
|
||||
ILogger logger,
|
||||
int batchSize = 100,
|
||||
long capacity = DefaultCapacity,
|
||||
TimeSpan? deadLetterRetention = null,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(databasePath))
|
||||
throw new ArgumentException("Database path required.", nameof(databasePath));
|
||||
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_batchSize = batchSize > 0 ? batchSize : throw new ArgumentOutOfRangeException(nameof(batchSize));
|
||||
_capacity = capacity > 0 ? capacity : throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
_deadLetterRetention = deadLetterRetention ?? DefaultDeadLetterRetention;
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
_connectionString = $"Data Source={databasePath}";
|
||||
|
||||
InitializeSchema();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start the background drain worker. Not started automatically so tests can
|
||||
/// drive <see cref="DrainOnceAsync"/> deterministically.
|
||||
/// </summary>
|
||||
public void StartDrainLoop(TimeSpan tickInterval)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
|
||||
_drainTimer?.Dispose();
|
||||
_drainTimer = new Timer(_ => _ = DrainOnceAsync(CancellationToken.None),
|
||||
null, tickInterval, tickInterval);
|
||||
}
|
||||
|
||||
public Task EnqueueAsync(AlarmHistorianEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is null) throw new ArgumentNullException(nameof(evt));
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(SqliteStoreAndForwardSink));
|
||||
|
||||
using var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
|
||||
EnforceCapacity(conn);
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
INSERT INTO Queue (AlarmId, EnqueuedUtc, PayloadJson, AttemptCount)
|
||||
VALUES ($alarmId, $enqueued, $payload, 0);
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$alarmId", evt.AlarmId);
|
||||
cmd.Parameters.AddWithValue("$enqueued", _clock().ToString("O"));
|
||||
cmd.Parameters.AddWithValue("$payload", JsonSerializer.Serialize(evt));
|
||||
cmd.ExecuteNonQuery();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read up to <see cref="_batchSize"/> queued rows, forward through the writer,
|
||||
/// remove Ack'd rows, dead-letter PermanentFail rows, and extend the backoff
|
||||
/// on RetryPlease. Safe to call from multiple threads; the semaphore enforces
|
||||
/// serial execution.
|
||||
/// </summary>
|
||||
public async Task DrainOnceAsync(CancellationToken ct)
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!await _drainGate.WaitAsync(0, ct).ConfigureAwait(false)) return;
|
||||
try
|
||||
{
|
||||
_drainState = HistorianDrainState.Draining;
|
||||
_lastDrainUtc = _clock();
|
||||
|
||||
PurgeAgedDeadLetters();
|
||||
var (rowIds, events) = ReadBatch();
|
||||
if (rowIds.Count == 0)
|
||||
{
|
||||
_drainState = HistorianDrainState.Idle;
|
||||
return;
|
||||
}
|
||||
|
||||
IReadOnlyList<HistorianWriteOutcome> outcomes;
|
||||
try
|
||||
{
|
||||
outcomes = await _writer.WriteBatchAsync(events, ct).ConfigureAwait(false);
|
||||
_lastError = null;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Writer-side exception — treat entire batch as RetryPlease.
|
||||
_lastError = ex.Message;
|
||||
_logger.Warning(ex, "Historian writer threw on batch of {Count}; deferring retry", events.Count);
|
||||
BumpBackoff();
|
||||
_drainState = HistorianDrainState.BackingOff;
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcomes.Count != events.Count)
|
||||
throw new InvalidOperationException(
|
||||
$"Writer returned {outcomes.Count} outcomes for {events.Count} events — expected 1:1");
|
||||
|
||||
using var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
using var tx = conn.BeginTransaction();
|
||||
for (var i = 0; i < outcomes.Count; i++)
|
||||
{
|
||||
var outcome = outcomes[i];
|
||||
var rowId = rowIds[i];
|
||||
switch (outcome)
|
||||
{
|
||||
case HistorianWriteOutcome.Ack:
|
||||
DeleteRow(conn, tx, rowId);
|
||||
break;
|
||||
case HistorianWriteOutcome.PermanentFail:
|
||||
DeadLetterRow(conn, tx, rowId, $"permanent fail at {_clock():O}");
|
||||
break;
|
||||
case HistorianWriteOutcome.RetryPlease:
|
||||
BumpAttempt(conn, tx, rowId, "retry-please");
|
||||
break;
|
||||
}
|
||||
}
|
||||
tx.Commit();
|
||||
|
||||
var acks = outcomes.Count(o => o == HistorianWriteOutcome.Ack);
|
||||
if (acks > 0) _lastSuccessUtc = _clock();
|
||||
|
||||
if (outcomes.Any(o => o == HistorianWriteOutcome.RetryPlease))
|
||||
{
|
||||
BumpBackoff();
|
||||
_drainState = HistorianDrainState.BackingOff;
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetBackoff();
|
||||
_drainState = HistorianDrainState.Idle;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_drainGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public HistorianSinkStatus GetStatus()
|
||||
{
|
||||
using var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
|
||||
long queued;
|
||||
long deadlettered;
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
|
||||
queued = (long)(cmd.ExecuteScalar() ?? 0L);
|
||||
}
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 1";
|
||||
deadlettered = (long)(cmd.ExecuteScalar() ?? 0L);
|
||||
}
|
||||
|
||||
return new HistorianSinkStatus(
|
||||
QueueDepth: queued,
|
||||
DeadLetterDepth: deadlettered,
|
||||
LastDrainUtc: _lastDrainUtc,
|
||||
LastSuccessUtc: _lastSuccessUtc,
|
||||
LastError: _lastError,
|
||||
DrainState: _drainState);
|
||||
}
|
||||
|
||||
/// <summary>Operator action from Admin UI — retry every dead-lettered row. Non-cascading: they rejoin the regular queue + get a fresh backoff.</summary>
|
||||
public int RetryDeadLettered()
|
||||
{
|
||||
using var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE Queue SET DeadLettered = 0, AttemptCount = 0, LastError = NULL WHERE DeadLettered = 1";
|
||||
return cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private (List<long> rowIds, List<AlarmHistorianEvent> events) ReadBatch()
|
||||
{
|
||||
var rowIds = new List<long>();
|
||||
var events = new List<AlarmHistorianEvent>();
|
||||
using var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
SELECT RowId, PayloadJson FROM Queue
|
||||
WHERE DeadLettered = 0
|
||||
ORDER BY RowId ASC
|
||||
LIMIT $limit
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$limit", _batchSize);
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rowIds.Add(reader.GetInt64(0));
|
||||
var payload = reader.GetString(1);
|
||||
var evt = JsonSerializer.Deserialize<AlarmHistorianEvent>(payload);
|
||||
if (evt is not null) events.Add(evt);
|
||||
}
|
||||
return (rowIds, events);
|
||||
}
|
||||
|
||||
private static void DeleteRow(SqliteConnection conn, SqliteTransaction tx, long rowId)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = "DELETE FROM Queue WHERE RowId = $id";
|
||||
cmd.Parameters.AddWithValue("$id", rowId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void DeadLetterRow(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
UPDATE Queue SET DeadLettered = 1, LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
|
||||
WHERE RowId = $id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
|
||||
cmd.Parameters.AddWithValue("$err", reason);
|
||||
cmd.Parameters.AddWithValue("$id", rowId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void BumpAttempt(SqliteConnection conn, SqliteTransaction tx, long rowId, string reason)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.Transaction = tx;
|
||||
cmd.CommandText = """
|
||||
UPDATE Queue SET LastAttemptUtc = $now, LastError = $err, AttemptCount = AttemptCount + 1
|
||||
WHERE RowId = $id
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$now", _clock().ToString("O"));
|
||||
cmd.Parameters.AddWithValue("$err", reason);
|
||||
cmd.Parameters.AddWithValue("$id", rowId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void EnforceCapacity(SqliteConnection conn)
|
||||
{
|
||||
// Count non-dead-lettered rows only — dead-lettered rows retain for
|
||||
// post-mortem per the configured retention window.
|
||||
long count;
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM Queue WHERE DeadLettered = 0";
|
||||
count = (long)(cmd.ExecuteScalar() ?? 0L);
|
||||
}
|
||||
if (count < _capacity) return;
|
||||
|
||||
var toEvict = count - _capacity + 1;
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = """
|
||||
DELETE FROM Queue
|
||||
WHERE RowId IN (
|
||||
SELECT RowId FROM Queue
|
||||
WHERE DeadLettered = 0
|
||||
ORDER BY RowId ASC
|
||||
LIMIT $n
|
||||
)
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$n", toEvict);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
_logger.Warning(
|
||||
"Historian queue at capacity {Cap} — evicted {Count} oldest row(s) to make room",
|
||||
_capacity, toEvict);
|
||||
}
|
||||
|
||||
private void PurgeAgedDeadLetters()
|
||||
{
|
||||
var cutoff = (_clock() - _deadLetterRetention).ToString("O");
|
||||
using var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
DELETE FROM Queue
|
||||
WHERE DeadLettered = 1 AND LastAttemptUtc IS NOT NULL AND LastAttemptUtc < $cutoff
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$cutoff", cutoff);
|
||||
var purged = cmd.ExecuteNonQuery();
|
||||
if (purged > 0)
|
||||
_logger.Information("Purged {Count} dead-lettered row(s) past retention window", purged);
|
||||
}
|
||||
|
||||
private void InitializeSchema()
|
||||
{
|
||||
using var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS Queue (
|
||||
RowId INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
AlarmId TEXT NOT NULL,
|
||||
EnqueuedUtc TEXT NOT NULL,
|
||||
PayloadJson TEXT NOT NULL,
|
||||
AttemptCount INTEGER NOT NULL DEFAULT 0,
|
||||
LastAttemptUtc TEXT NULL,
|
||||
LastError TEXT NULL,
|
||||
DeadLettered INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS IX_Queue_Drain ON Queue (DeadLettered, RowId);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private void BumpBackoff() => _backoffIndex = Math.Min(_backoffIndex + 1, BackoffLadder.Length - 1);
|
||||
private void ResetBackoff() => _backoffIndex = 0;
|
||||
public TimeSpan CurrentBackoff => BackoffLadder[_backoffIndex];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_drainTimer?.Dispose();
|
||||
_drainGate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Persistent per-alarm state tracked by the Part 9 state machine. Every field
|
||||
/// carried here either participates in the state machine or contributes to the
|
||||
/// audit trail required by Phase 7 plan decision #14 (GxP / 21 CFR Part 11).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Active"/> is re-derived from the predicate at startup per Phase 7
|
||||
/// decision #14 — the engine runs every alarm's predicate against current tag
|
||||
/// values at <c>Load</c>, overriding whatever Active state is in the store.
|
||||
/// Every other state field persists verbatim across server restarts so
|
||||
/// operators don't re-ack active alarms after an outage + shelved alarms stay
|
||||
/// shelved + audit history survives.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Comments"/> is append-only; comments + ack/confirm user identities
|
||||
/// are the audit surface regulators consume. The engine never rewrites past
|
||||
/// entries.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record AlarmConditionState(
|
||||
string AlarmId,
|
||||
AlarmEnabledState Enabled,
|
||||
AlarmActiveState Active,
|
||||
AlarmAckedState Acked,
|
||||
AlarmConfirmedState Confirmed,
|
||||
ShelvingState Shelving,
|
||||
DateTime LastTransitionUtc,
|
||||
DateTime? LastActiveUtc,
|
||||
DateTime? LastClearedUtc,
|
||||
DateTime? LastAckUtc,
|
||||
string? LastAckUser,
|
||||
string? LastAckComment,
|
||||
DateTime? LastConfirmUtc,
|
||||
string? LastConfirmUser,
|
||||
string? LastConfirmComment,
|
||||
IReadOnlyList<AlarmComment> Comments)
|
||||
{
|
||||
/// <summary>Initial-load state for a newly registered alarm — everything in the "no-event" position.</summary>
|
||||
public static AlarmConditionState Fresh(string alarmId, DateTime nowUtc) => new(
|
||||
AlarmId: alarmId,
|
||||
Enabled: AlarmEnabledState.Enabled,
|
||||
Active: AlarmActiveState.Inactive,
|
||||
Acked: AlarmAckedState.Acknowledged,
|
||||
Confirmed: AlarmConfirmedState.Confirmed,
|
||||
Shelving: ShelvingState.Unshelved,
|
||||
LastTransitionUtc: nowUtc,
|
||||
LastActiveUtc: null,
|
||||
LastClearedUtc: null,
|
||||
LastAckUtc: null,
|
||||
LastAckUser: null,
|
||||
LastAckComment: null,
|
||||
LastConfirmUtc: null,
|
||||
LastConfirmUser: null,
|
||||
LastConfirmComment: null,
|
||||
Comments: []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shelving state — kind plus, for <see cref="ShelvingKind.Timed"/>, the UTC
|
||||
/// timestamp at which the shelving auto-expires. The engine polls the timer on its
|
||||
/// evaluation cadence; callers should not rely on millisecond-precision expiry.
|
||||
/// </summary>
|
||||
public sealed record ShelvingState(ShelvingKind Kind, DateTime? UnshelveAtUtc)
|
||||
{
|
||||
public static readonly ShelvingState Unshelved = new(ShelvingKind.Unshelved, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single append-only audit record — acknowledgement / confirmation / explicit
|
||||
/// comment / shelving action. Every entry carries a monotonic UTC timestamp plus the
|
||||
/// user identity Phase 6.2 authenticated.
|
||||
/// </summary>
|
||||
/// <param name="TimestampUtc">When the action happened.</param>
|
||||
/// <param name="User">OS / LDAP identity of the actor. For engine-internal events (shelving expiry, startup recovery) this is <c>"system"</c>.</param>
|
||||
/// <param name="Kind">Human-readable classification — "Acknowledge", "Confirm", "ShelveOneShot", "ShelveTimed", "Unshelve", "AddComment", "Enable", "Disable", "AutoUnshelve".</param>
|
||||
/// <param name="Text">Operator-supplied comment or engine-generated message.</param>
|
||||
public sealed record AlarmComment(
|
||||
DateTime TimestampUtc,
|
||||
string User,
|
||||
string Kind,
|
||||
string Text);
|
||||
@@ -0,0 +1,55 @@
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ScriptContext"/> subclass for alarm predicate evaluation. Reads from
|
||||
/// the engine's shared tag cache (driver + virtual tags), writes are rejected —
|
||||
/// predicates must be side-effect free so their output doesn't depend on evaluation
|
||||
/// order or drive cascade behavior.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per Phase 7 plan Shape A decision, alarm scripts are one-script-per-alarm
|
||||
/// returning <c>bool</c>. They read any tag they want but should not write
|
||||
/// anything (the owning alarm's state is tracked by the engine, not the script).
|
||||
/// </remarks>
|
||||
public sealed class AlarmPredicateContext : ScriptContext
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
public AlarmPredicateContext(
|
||||
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
|
||||
ILogger logger,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
|
||||
return _readCache.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, _clock());
|
||||
}
|
||||
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
// Predicates must be pure — writing from an alarm script couples alarm state to
|
||||
// virtual-tag state in a way that's near-impossible to reason about. Rejected
|
||||
// at runtime with a clear message; operators see it in the scripts-*.log.
|
||||
throw new InvalidOperationException(
|
||||
"Alarm predicate scripts cannot write to virtual tags. Move the write logic " +
|
||||
"into a virtual tag whose value the alarm predicate then reads.");
|
||||
}
|
||||
|
||||
public override DateTime Now => _clock();
|
||||
|
||||
public override ILogger Logger { get; }
|
||||
}
|
||||
40
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs
Normal file
40
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/AlarmTypes.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// The concrete OPC UA Part 9 alarm subtype a scripted alarm materializes as. The
|
||||
/// engine's internal state machine is identical regardless of kind — the
|
||||
/// <c>AlarmKind</c> only affects how the alarm node appears to OPC UA clients
|
||||
/// (which ObjectType it maps to) and what diagnostic fields are populated.
|
||||
/// </summary>
|
||||
public enum AlarmKind
|
||||
{
|
||||
/// <summary>Base AlarmConditionType — no numeric or discrete interpretation.</summary>
|
||||
AlarmCondition,
|
||||
/// <summary>LimitAlarmType — the condition reflects a numeric setpoint / threshold breach.</summary>
|
||||
LimitAlarm,
|
||||
/// <summary>DiscreteAlarmType — the condition reflects a specific discrete value match.</summary>
|
||||
DiscreteAlarm,
|
||||
/// <summary>OffNormalAlarmType — the condition reflects deviation from a configured "normal" state.</summary>
|
||||
OffNormalAlarm,
|
||||
}
|
||||
|
||||
/// <summary>OPC UA Part 9 EnabledState — operator-controlled alarm enable/disable.</summary>
|
||||
public enum AlarmEnabledState { Enabled, Disabled }
|
||||
|
||||
/// <summary>OPC UA Part 9 ActiveState — reflects the current predicate truth.</summary>
|
||||
public enum AlarmActiveState { Inactive, Active }
|
||||
|
||||
/// <summary>OPC UA Part 9 AckedState — operator has acknowledged the active transition.</summary>
|
||||
public enum AlarmAckedState { Unacknowledged, Acknowledged }
|
||||
|
||||
/// <summary>OPC UA Part 9 ConfirmedState — operator has confirmed the clear transition.</summary>
|
||||
public enum AlarmConfirmedState { Unconfirmed, Confirmed }
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Part 9 shelving mode.
|
||||
/// <see cref="OneShot"/> suppresses the next active transition; once cleared
|
||||
/// the shelving expires and the alarm returns to normal behavior.
|
||||
/// <see cref="Timed"/> suppresses until a configured expiry timestamp passes.
|
||||
/// <see cref="Unshelved"/> is the default state — no suppression.
|
||||
/// </summary>
|
||||
public enum ShelvingKind { Unshelved, OneShot, Timed }
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence for <see cref="AlarmConditionState"/> across server restarts. Phase 7
|
||||
/// plan decision #14: operator-supplied state (EnabledState / AckedState /
|
||||
/// ConfirmedState / ShelvingState + audit trail) persists; ActiveState is
|
||||
/// recomputed from the live predicate on startup so operators never re-ack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stream E wires this to a SQL-backed store against the <c>ScriptedAlarmState</c>
|
||||
/// table with audit logging through <see cref="Core.Abstractions"/> IAuditLogger.
|
||||
/// Tests + local dev use <see cref="InMemoryAlarmStateStore"/>.
|
||||
/// </remarks>
|
||||
public interface IAlarmStateStore
|
||||
{
|
||||
Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct);
|
||||
Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct);
|
||||
Task SaveAsync(AlarmConditionState state, CancellationToken ct);
|
||||
Task RemoveAsync(string alarmId, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>In-memory default — used by tests + by dev deployments without a SQL backend.</summary>
|
||||
public sealed class InMemoryAlarmStateStore : IAlarmStateStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, AlarmConditionState> _map
|
||||
= new(StringComparer.Ordinal);
|
||||
|
||||
public Task<AlarmConditionState?> LoadAsync(string alarmId, CancellationToken ct)
|
||||
=> Task.FromResult(_map.TryGetValue(alarmId, out var v) ? v : null);
|
||||
|
||||
public Task<IReadOnlyList<AlarmConditionState>> LoadAllAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<AlarmConditionState>>(_map.Values.ToArray());
|
||||
|
||||
public Task SaveAsync(AlarmConditionState state, CancellationToken ct)
|
||||
{
|
||||
_map[state.AlarmId] = state;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string alarmId, CancellationToken ct)
|
||||
{
|
||||
_map.TryRemove(alarmId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/MessageTemplate.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #13, alarm messages are static-with-substitution
|
||||
/// templates. The engine resolves <c>{TagPath}</c> tokens at event emission time
|
||||
/// against current tag values; unresolvable tokens become <c>{?}</c> so the event
|
||||
/// still fires but the operator sees where the reference broke.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Token syntax: <c>{path/with/slashes}</c>. Brace-stripped the contents must
|
||||
/// match a path the caller's resolver function can look up. No escaping
|
||||
/// currently — if you need literal braces in the message, reach for a feature
|
||||
/// request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pure function. Same inputs always produce the same string. Tests verify the
|
||||
/// edge cases (no tokens / one token / many / nested / unresolvable / bad
|
||||
/// quality / null value).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MessageTemplate
|
||||
{
|
||||
private static readonly Regex TokenRegex = new(@"\{([^{}]+)\}",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve every <c>{path}</c> token in <paramref name="template"/> using
|
||||
/// <paramref name="resolveTag"/>. Tokens whose returned <see cref="DataValueSnapshot"/>
|
||||
/// has a non-Good <see cref="DataValueSnapshot.StatusCode"/> or a null
|
||||
/// <see cref="DataValueSnapshot.Value"/> resolve to <c>{?}</c>.
|
||||
/// </summary>
|
||||
public static string Resolve(string template, Func<string, DataValueSnapshot?> resolveTag)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template)) return template ?? string.Empty;
|
||||
if (resolveTag is null) throw new ArgumentNullException(nameof(resolveTag));
|
||||
|
||||
return TokenRegex.Replace(template, match =>
|
||||
{
|
||||
var path = match.Groups[1].Value.Trim();
|
||||
if (path.Length == 0) return "{?}";
|
||||
var snap = resolveTag(path);
|
||||
if (snap is null) return "{?}";
|
||||
if (snap.StatusCode != 0u) return "{?}";
|
||||
return snap.Value?.ToString() ?? "{?}";
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Enumerate the token paths the template references. Used at publish time to validate references exist.</summary>
|
||||
public static IReadOnlyList<string> ExtractTokenPaths(string? template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template)) return Array.Empty<string>();
|
||||
var tokens = new List<string>();
|
||||
foreach (Match m in TokenRegex.Matches(template))
|
||||
{
|
||||
var path = m.Groups[1].Value.Trim();
|
||||
if (path.Length > 0) tokens.Add(path);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
294
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs
Normal file
294
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/Part9StateMachine.cs
Normal file
@@ -0,0 +1,294 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Pure functions for OPC UA Part 9 alarm-condition state transitions. Input = the
|
||||
/// current <see cref="AlarmConditionState"/> + the event; output = the new state +
|
||||
/// optional emission hint. The engine calls these; persistence happens around them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// No instance state, no I/O, no mutation of the input record. Every transition
|
||||
/// returns a fresh record. Makes the state machine trivially unit-testable —
|
||||
/// tests assert on (input, event) -> (output) without standing anything else up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two invariants the machine enforces:
|
||||
/// (1) Disabled alarms never transition ActiveState / AckedState / ConfirmedState
|
||||
/// — all predicate evaluations while disabled produce a no-op result and a
|
||||
/// diagnostic log line. Re-enable restores normal flow with ActiveState
|
||||
/// re-derived from the next predicate evaluation.
|
||||
/// (2) Shelved alarms (OneShot / Timed) don't fire active transitions to
|
||||
/// subscribers, but the state record still advances so that when shelving
|
||||
/// expires the ActiveState reflects current reality. OneShot expires on the
|
||||
/// next clear; Timed expires at <see cref="ShelvingState.UnshelveAtUtc"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Part9StateMachine
|
||||
{
|
||||
/// <summary>
|
||||
/// Apply a predicate re-evaluation result. Handles activation, clearing,
|
||||
/// branch-stack increment when a new active arrives while prior active is
|
||||
/// still un-acked, and shelving suppression.
|
||||
/// </summary>
|
||||
public static TransitionResult ApplyPredicate(
|
||||
AlarmConditionState current,
|
||||
bool predicateTrue,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
if (current.Enabled == AlarmEnabledState.Disabled)
|
||||
return TransitionResult.NoOp(current, "disabled — predicate result ignored");
|
||||
|
||||
// Expire timed shelving if the configured clock has passed.
|
||||
var shelving = MaybeExpireShelving(current.Shelving, nowUtc);
|
||||
var stateWithShelving = current with { Shelving = shelving };
|
||||
|
||||
// Shelved alarms still update state but skip event emission.
|
||||
var shelved = shelving.Kind != ShelvingKind.Unshelved;
|
||||
|
||||
if (predicateTrue && current.Active == AlarmActiveState.Inactive)
|
||||
{
|
||||
// Inactive -> Active transition.
|
||||
// OneShotShelving is consumed on the NEXT clear, not activation — so we
|
||||
// still suppress this transition's emission.
|
||||
var next = stateWithShelving with
|
||||
{
|
||||
Active = AlarmActiveState.Active,
|
||||
Acked = AlarmAckedState.Unacknowledged,
|
||||
Confirmed = AlarmConfirmedState.Unconfirmed,
|
||||
LastActiveUtc = nowUtc,
|
||||
LastTransitionUtc = nowUtc,
|
||||
};
|
||||
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Activated);
|
||||
}
|
||||
|
||||
if (!predicateTrue && current.Active == AlarmActiveState.Active)
|
||||
{
|
||||
// Active -> Inactive transition.
|
||||
var next = stateWithShelving with
|
||||
{
|
||||
Active = AlarmActiveState.Inactive,
|
||||
LastClearedUtc = nowUtc,
|
||||
LastTransitionUtc = nowUtc,
|
||||
// OneShotShelving expires on clear — resetting here so the next
|
||||
// activation fires normally.
|
||||
Shelving = shelving.Kind == ShelvingKind.OneShot
|
||||
? ShelvingState.Unshelved
|
||||
: shelving,
|
||||
};
|
||||
return new TransitionResult(next, shelved ? EmissionKind.Suppressed : EmissionKind.Cleared);
|
||||
}
|
||||
|
||||
// Predicate matches current Active — no state change beyond possible shelving
|
||||
// expiry.
|
||||
return new TransitionResult(stateWithShelving, EmissionKind.None);
|
||||
}
|
||||
|
||||
/// <summary>Operator acknowledges the currently-active transition.</summary>
|
||||
public static TransitionResult ApplyAcknowledge(
|
||||
AlarmConditionState current,
|
||||
string user,
|
||||
string? comment,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user))
|
||||
throw new ArgumentException("User identity required for audit.", nameof(user));
|
||||
|
||||
if (current.Acked == AlarmAckedState.Acknowledged)
|
||||
return TransitionResult.NoOp(current, "already acknowledged");
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "Acknowledge", comment);
|
||||
var next = current with
|
||||
{
|
||||
Acked = AlarmAckedState.Acknowledged,
|
||||
LastAckUtc = nowUtc,
|
||||
LastAckUser = user,
|
||||
LastAckComment = comment,
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Acknowledged);
|
||||
}
|
||||
|
||||
/// <summary>Operator confirms the cleared transition. Part 9 requires confirm after clear for retain-flag alarms.</summary>
|
||||
public static TransitionResult ApplyConfirm(
|
||||
AlarmConditionState current,
|
||||
string user,
|
||||
string? comment,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user))
|
||||
throw new ArgumentException("User identity required for audit.", nameof(user));
|
||||
|
||||
if (current.Confirmed == AlarmConfirmedState.Confirmed)
|
||||
return TransitionResult.NoOp(current, "already confirmed");
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "Confirm", comment);
|
||||
var next = current with
|
||||
{
|
||||
Confirmed = AlarmConfirmedState.Confirmed,
|
||||
LastConfirmUtc = nowUtc,
|
||||
LastConfirmUser = user,
|
||||
LastConfirmComment = comment,
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Confirmed);
|
||||
}
|
||||
|
||||
public static TransitionResult ApplyOneShotShelve(
|
||||
AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
if (current.Shelving.Kind == ShelvingKind.OneShot)
|
||||
return TransitionResult.NoOp(current, "already one-shot shelved");
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveOneShot", null);
|
||||
var next = current with
|
||||
{
|
||||
Shelving = new ShelvingState(ShelvingKind.OneShot, null),
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Shelved);
|
||||
}
|
||||
|
||||
public static TransitionResult ApplyTimedShelve(
|
||||
AlarmConditionState current, string user, DateTime unshelveAtUtc, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
if (unshelveAtUtc <= nowUtc)
|
||||
throw new ArgumentOutOfRangeException(nameof(unshelveAtUtc), "Unshelve time must be in the future.");
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "ShelveTimed",
|
||||
$"UnshelveAtUtc={unshelveAtUtc:O}");
|
||||
var next = current with
|
||||
{
|
||||
Shelving = new ShelvingState(ShelvingKind.Timed, unshelveAtUtc),
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Shelved);
|
||||
}
|
||||
|
||||
public static TransitionResult ApplyUnshelve(AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
if (current.Shelving.Kind == ShelvingKind.Unshelved)
|
||||
return TransitionResult.NoOp(current, "not shelved");
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "Unshelve", null);
|
||||
var next = current with
|
||||
{
|
||||
Shelving = ShelvingState.Unshelved,
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Unshelved);
|
||||
}
|
||||
|
||||
public static TransitionResult ApplyEnable(AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
if (current.Enabled == AlarmEnabledState.Enabled)
|
||||
return TransitionResult.NoOp(current, "already enabled");
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "Enable", null);
|
||||
var next = current with
|
||||
{
|
||||
Enabled = AlarmEnabledState.Enabled,
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Enabled);
|
||||
}
|
||||
|
||||
public static TransitionResult ApplyDisable(AlarmConditionState current, string user, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
if (current.Enabled == AlarmEnabledState.Disabled)
|
||||
return TransitionResult.NoOp(current, "already disabled");
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "Disable", null);
|
||||
var next = current with
|
||||
{
|
||||
Enabled = AlarmEnabledState.Disabled,
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Disabled);
|
||||
}
|
||||
|
||||
public static TransitionResult ApplyAddComment(
|
||||
AlarmConditionState current, string user, string text, DateTime nowUtc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(user)) throw new ArgumentException("User required.", nameof(user));
|
||||
if (string.IsNullOrWhiteSpace(text)) throw new ArgumentException("Comment text required.", nameof(text));
|
||||
|
||||
var audit = AppendComment(current.Comments, nowUtc, user, "AddComment", text);
|
||||
var next = current with { Comments = audit };
|
||||
return new TransitionResult(next, EmissionKind.CommentAdded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluate whether a currently timed-shelved alarm has expired. Returns
|
||||
/// the (possibly unshelved) state + emission hint so the engine knows to
|
||||
/// publish an Unshelved event at the right moment.
|
||||
/// </summary>
|
||||
public static TransitionResult ApplyShelvingCheck(AlarmConditionState current, DateTime nowUtc)
|
||||
{
|
||||
if (current.Shelving.Kind != ShelvingKind.Timed) return TransitionResult.None(current);
|
||||
if (current.Shelving.UnshelveAtUtc is DateTime t && nowUtc >= t)
|
||||
{
|
||||
var audit = AppendComment(current.Comments, nowUtc, "system", "AutoUnshelve",
|
||||
$"Timed shelving expired at {nowUtc:O}");
|
||||
var next = current with
|
||||
{
|
||||
Shelving = ShelvingState.Unshelved,
|
||||
LastTransitionUtc = nowUtc,
|
||||
Comments = audit,
|
||||
};
|
||||
return new TransitionResult(next, EmissionKind.Unshelved);
|
||||
}
|
||||
return TransitionResult.None(current);
|
||||
}
|
||||
|
||||
private static ShelvingState MaybeExpireShelving(ShelvingState s, DateTime nowUtc)
|
||||
{
|
||||
if (s.Kind != ShelvingKind.Timed) return s;
|
||||
return s.UnshelveAtUtc is DateTime t && nowUtc >= t ? ShelvingState.Unshelved : s;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AlarmComment> AppendComment(
|
||||
IReadOnlyList<AlarmComment> existing, DateTime ts, string user, string kind, string? text)
|
||||
{
|
||||
var list = new List<AlarmComment>(existing.Count + 1);
|
||||
list.AddRange(existing);
|
||||
list.Add(new AlarmComment(ts, user, kind, text ?? string.Empty));
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of a state-machine operation — new state + what to emit (if anything).</summary>
|
||||
public sealed record TransitionResult(AlarmConditionState State, EmissionKind Emission)
|
||||
{
|
||||
public static TransitionResult None(AlarmConditionState state) => new(state, EmissionKind.None);
|
||||
public static TransitionResult NoOp(AlarmConditionState state, string reason) => new(state, EmissionKind.None);
|
||||
}
|
||||
|
||||
/// <summary>What kind of event, if any, the engine should emit after a transition.</summary>
|
||||
public enum EmissionKind
|
||||
{
|
||||
/// <summary>State did not change meaningfully — no event to emit.</summary>
|
||||
None,
|
||||
/// <summary>Predicate transitioned to true while shelving was suppressing events.</summary>
|
||||
Suppressed,
|
||||
Activated,
|
||||
Cleared,
|
||||
Acknowledged,
|
||||
Confirmed,
|
||||
Shelved,
|
||||
Unshelved,
|
||||
Enabled,
|
||||
Disabled,
|
||||
CommentAdded,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Operator-authored scripted-alarm configuration. Phase 7 Stream E (config DB schema)
|
||||
/// materializes these from the <c>ScriptedAlarm</c> + <c>Script</c> tables on publish.
|
||||
/// </summary>
|
||||
/// <param name="AlarmId">
|
||||
/// Stable identity for the alarm — used as the OPC UA ConditionId + the key in the
|
||||
/// state store. Should be globally unique within the cluster; convention is
|
||||
/// <c>{EquipmentPath}::{AlarmName}</c>.
|
||||
/// </param>
|
||||
/// <param name="EquipmentPath">
|
||||
/// UNS path of the Equipment node the alarm hangs under. Alarm browse lives here;
|
||||
/// ACL binding inherits this equipment's scope per Phase 6.2.
|
||||
/// </param>
|
||||
/// <param name="AlarmName">Human-readable alarm name — used in the browse tree + Admin UI.</param>
|
||||
/// <param name="Kind">Concrete OPC UA Part 9 subtype the alarm materializes as.</param>
|
||||
/// <param name="Severity">Static severity per Phase 7 plan decision #13; not currently computed by the predicate.</param>
|
||||
/// <param name="MessageTemplate">
|
||||
/// Message text with <c>{TagPath}</c> tokens resolved at event-emission time per
|
||||
/// Phase 7 plan decision #13. Unresolvable tokens emit <c>{?}</c> + a structured
|
||||
/// error so operators can spot stale references.
|
||||
/// </param>
|
||||
/// <param name="PredicateScriptSource">
|
||||
/// Roslyn C# script returning <c>bool</c>. <c>true</c> = alarm condition currently holds (active);
|
||||
/// <c>false</c> = condition has cleared. Same sandbox rules as virtual tags per Phase 7 decision #6.
|
||||
/// </param>
|
||||
/// <param name="HistorizeToAveva">
|
||||
/// When true, every transition emission of this alarm flows to the Historian alarm
|
||||
/// sink (Stream D). Defaults to true — plant alarm history is usually the
|
||||
/// operator's primary diagnostic. Galaxy-native alarms default false since Galaxy
|
||||
/// historises them directly.
|
||||
/// </param>
|
||||
/// <param name="Retain">
|
||||
/// Part 9 retain flag — when true, the condition node remains visible after the
|
||||
/// predicate clears as long as it has un-acknowledged or un-confirmed transitions.
|
||||
/// Default true.
|
||||
/// </param>
|
||||
public sealed record ScriptedAlarmDefinition(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
string AlarmName,
|
||||
AlarmKind Kind,
|
||||
AlarmSeverity Severity,
|
||||
string MessageTemplate,
|
||||
string PredicateScriptSource,
|
||||
bool HistorizeToAveva = true,
|
||||
bool Retain = true);
|
||||
429
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
Normal file
429
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmEngine.cs
Normal file
@@ -0,0 +1,429 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 scripted-alarm orchestrator. Compiles every configured alarm's predicate
|
||||
/// against the Stream A sandbox, subscribes to the referenced upstream tags,
|
||||
/// re-evaluates the predicate on every input change + on a shelving-check timer,
|
||||
/// applies the resulting transition through <see cref="Part9StateMachine"/>,
|
||||
/// persists state via <see cref="IAlarmStateStore"/>, and emits the resulting events
|
||||
/// through <see cref="ScriptedAlarmSource"/> (which wires into the existing
|
||||
/// <c>IAlarmSource</c> fan-out).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Scripted alarms are leaves in the evaluation DAG — no alarm's state drives
|
||||
/// another alarm's predicate. The engine maintains only an inverse index from
|
||||
/// upstream tag path → alarms referencing it; no topological sort needed
|
||||
/// (unlike the virtual-tag engine).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Evaluation errors (script throws, timeout, coercion fail) surface as
|
||||
/// structured errors in the dedicated scripts-*.log sink plus a WARN companion
|
||||
/// in the main log. The alarm's ActiveState stays at its prior value — the
|
||||
/// engine does NOT invent a clear transition just because the predicate broke.
|
||||
/// Operators investigating a broken predicate shouldn't see a phantom
|
||||
/// clear-event preceding the failure.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarmEngine : IDisposable
|
||||
{
|
||||
private readonly ITagUpstreamSource _upstream;
|
||||
private readonly IAlarmStateStore _store;
|
||||
private readonly ScriptLoggerFactory _loggerFactory;
|
||||
private readonly ILogger _engineLogger;
|
||||
private readonly Func<DateTime> _clock;
|
||||
private readonly TimeSpan _scriptTimeout;
|
||||
|
||||
private readonly Dictionary<string, AlarmState> _alarms = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HashSet<string>> _alarmsReferencing
|
||||
= new(StringComparer.Ordinal); // tag path -> alarm ids
|
||||
|
||||
private readonly List<IDisposable> _upstreamSubscriptions = [];
|
||||
private readonly SemaphoreSlim _evalGate = new(1, 1);
|
||||
private Timer? _shelvingTimer;
|
||||
private bool _loaded;
|
||||
private bool _disposed;
|
||||
|
||||
public ScriptedAlarmEngine(
|
||||
ITagUpstreamSource upstream,
|
||||
IAlarmStateStore store,
|
||||
ScriptLoggerFactory loggerFactory,
|
||||
ILogger engineLogger,
|
||||
Func<DateTime>? clock = null,
|
||||
TimeSpan? scriptTimeout = null)
|
||||
{
|
||||
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<AlarmPredicateContext, bool>.DefaultTimeout;
|
||||
}
|
||||
|
||||
/// <summary>Raised for every emission the Part9StateMachine produces that the engine should publish.</summary>
|
||||
public event EventHandler<ScriptedAlarmEvent>? OnEvent;
|
||||
|
||||
public IReadOnlyCollection<string> LoadedAlarmIds => _alarms.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Load a batch of alarm definitions. Compiles every predicate, aggregates any
|
||||
/// compile failures into one <see cref="InvalidOperationException"/>, subscribes
|
||||
/// to upstream input tags, seeds the value cache, loads persisted state from
|
||||
/// the store (falling back to Fresh for first-load alarms), and recomputes
|
||||
/// ActiveState per Phase 7 plan decision #14 (startup recovery).
|
||||
/// </summary>
|
||||
public async Task LoadAsync(IReadOnlyList<ScriptedAlarmDefinition> definitions, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ScriptedAlarmEngine));
|
||||
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
|
||||
|
||||
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
UnsubscribeFromUpstream();
|
||||
_alarms.Clear();
|
||||
_alarmsReferencing.Clear();
|
||||
|
||||
var compileFailures = new List<string>();
|
||||
foreach (var def in definitions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var extraction = DependencyExtractor.Extract(def.PredicateScriptSource);
|
||||
if (!extraction.IsValid)
|
||||
{
|
||||
var joined = string.Join("; ", extraction.Rejections.Select(r => r.Message));
|
||||
compileFailures.Add($"{def.AlarmId}: dependency extraction rejected — {joined}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var evaluator = ScriptEvaluator<AlarmPredicateContext, bool>.Compile(def.PredicateScriptSource);
|
||||
var timed = new TimedScriptEvaluator<AlarmPredicateContext, bool>(evaluator, _scriptTimeout);
|
||||
var logger = _loggerFactory.Create(def.AlarmId);
|
||||
|
||||
var templateTokens = MessageTemplate.ExtractTokenPaths(def.MessageTemplate);
|
||||
var allInputs = new HashSet<string>(extraction.Reads, StringComparer.Ordinal);
|
||||
foreach (var t in templateTokens) allInputs.Add(t);
|
||||
|
||||
_alarms[def.AlarmId] = new AlarmState(def, timed, extraction.Reads, templateTokens, logger,
|
||||
AlarmConditionState.Fresh(def.AlarmId, _clock()));
|
||||
|
||||
foreach (var path in allInputs)
|
||||
{
|
||||
if (!_alarmsReferencing.TryGetValue(path, out var set))
|
||||
_alarmsReferencing[path] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(def.AlarmId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
compileFailures.Add($"{def.AlarmId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (compileFailures.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"ScriptedAlarmEngine load failed. {compileFailures.Count} alarm(s) did not compile:\n "
|
||||
+ string.Join("\n ", compileFailures));
|
||||
}
|
||||
|
||||
// Seed the value cache with current upstream values + subscribe for changes.
|
||||
foreach (var path in _alarmsReferencing.Keys)
|
||||
{
|
||||
_valueCache[path] = _upstream.ReadTag(path);
|
||||
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
|
||||
}
|
||||
|
||||
// Restore persisted state, falling back to Fresh where nothing was saved,
|
||||
// then re-derive ActiveState from the current predicate per decision #14.
|
||||
foreach (var (alarmId, state) in _alarms)
|
||||
{
|
||||
var persisted = await _store.LoadAsync(alarmId, ct).ConfigureAwait(false);
|
||||
var seed = persisted ?? state.Condition;
|
||||
var afterPredicate = await EvaluatePredicateToStateAsync(state, seed, nowUtc: _clock(), ct)
|
||||
.ConfigureAwait(false);
|
||||
_alarms[alarmId] = state with { Condition = afterPredicate };
|
||||
await _store.SaveAsync(afterPredicate, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
_engineLogger.Information("ScriptedAlarmEngine loaded {Count} alarm(s)", _alarms.Count);
|
||||
|
||||
// Start the shelving-check timer — ticks every 5s, expires any timed shelves
|
||||
// that have passed their UnshelveAtUtc.
|
||||
_shelvingTimer = new Timer(_ => RunShelvingCheck(),
|
||||
null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evalGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current persisted state for <paramref name="alarmId"/>. Returns null for
|
||||
/// unknown alarm. Mainly used for diagnostics + the Admin UI status page.
|
||||
/// </summary>
|
||||
public AlarmConditionState? GetState(string alarmId)
|
||||
=> _alarms.TryGetValue(alarmId, out var s) ? s.Condition : null;
|
||||
|
||||
public IReadOnlyCollection<AlarmConditionState> GetAllStates()
|
||||
=> _alarms.Values.Select(a => a.Condition).ToArray();
|
||||
|
||||
public Task AcknowledgeAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAcknowledge(cur, user, comment, _clock()));
|
||||
|
||||
public Task ConfirmAsync(string alarmId, string user, string? comment, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyConfirm(cur, user, comment, _clock()));
|
||||
|
||||
public Task OneShotShelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyOneShotShelve(cur, user, _clock()));
|
||||
|
||||
public Task TimedShelveAsync(string alarmId, string user, DateTime unshelveAtUtc, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyTimedShelve(cur, user, unshelveAtUtc, _clock()));
|
||||
|
||||
public Task UnshelveAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyUnshelve(cur, user, _clock()));
|
||||
|
||||
public Task EnableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyEnable(cur, user, _clock()));
|
||||
|
||||
public Task DisableAsync(string alarmId, string user, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyDisable(cur, user, _clock()));
|
||||
|
||||
public Task AddCommentAsync(string alarmId, string user, string text, CancellationToken ct)
|
||||
=> ApplyAsync(alarmId, ct, cur => Part9StateMachine.ApplyAddComment(cur, user, text, _clock()));
|
||||
|
||||
private async Task ApplyAsync(string alarmId, CancellationToken ct, Func<AlarmConditionState, TransitionResult> op)
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (!_alarms.TryGetValue(alarmId, out var state))
|
||||
throw new ArgumentException($"Unknown alarm {alarmId}", nameof(alarmId));
|
||||
|
||||
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var result = op(state.Condition);
|
||||
_alarms[alarmId] = state with { Condition = result.State };
|
||||
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
|
||||
if (result.Emission != EmissionKind.None) EmitEvent(state, result.State, result.Emission);
|
||||
}
|
||||
finally { _evalGate.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upstream-change callback. Updates the value cache + enqueues predicate
|
||||
/// re-evaluation for every alarm referencing the changed path. Fire-and-forget
|
||||
/// so driver-side dispatch isn't blocked.
|
||||
/// </summary>
|
||||
internal void OnUpstreamChange(string path, DataValueSnapshot value)
|
||||
{
|
||||
_valueCache[path] = value;
|
||||
if (_alarmsReferencing.TryGetValue(path, out var alarmIds))
|
||||
{
|
||||
_ = ReevaluateAsync(alarmIds.ToArray(), CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReevaluateAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
foreach (var id in alarmIds)
|
||||
{
|
||||
if (!_alarms.TryGetValue(id, out var state)) continue;
|
||||
var newState = await EvaluatePredicateToStateAsync(
|
||||
state, state.Condition, _clock(), ct).ConfigureAwait(false);
|
||||
if (!ReferenceEquals(newState, state.Condition))
|
||||
{
|
||||
_alarms[id] = state with { Condition = newState };
|
||||
await _store.SaveAsync(newState, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _evalGate.Release(); }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_engineLogger.Error(ex, "ScriptedAlarmEngine reevaluate failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate the predicate + apply the resulting state-machine transition.
|
||||
/// Returns the new condition state. Emits the appropriate event if the
|
||||
/// transition produces one.
|
||||
/// </summary>
|
||||
private async Task<AlarmConditionState> EvaluatePredicateToStateAsync(
|
||||
AlarmState state, AlarmConditionState seed, DateTime nowUtc, CancellationToken ct)
|
||||
{
|
||||
var inputs = BuildReadCache(state.Inputs);
|
||||
var context = new AlarmPredicateContext(inputs, state.Logger, _clock);
|
||||
|
||||
bool predicateTrue;
|
||||
try
|
||||
{
|
||||
predicateTrue = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (ScriptTimeoutException tex)
|
||||
{
|
||||
state.Logger.Warning("Alarm predicate timed out after {Timeout} — state unchanged", tex.Timeout);
|
||||
return seed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
state.Logger.Error(ex, "Alarm predicate threw — state unchanged");
|
||||
return seed;
|
||||
}
|
||||
|
||||
var result = Part9StateMachine.ApplyPredicate(seed, predicateTrue, nowUtc);
|
||||
if (result.Emission != EmissionKind.None)
|
||||
EmitEvent(state, result.State, result.Emission);
|
||||
return result.State;
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> inputs)
|
||||
{
|
||||
var d = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var p in inputs)
|
||||
d[p] = _valueCache.TryGetValue(p, out var v) ? v : _upstream.ReadTag(p);
|
||||
return d;
|
||||
}
|
||||
|
||||
private void EmitEvent(AlarmState state, AlarmConditionState condition, EmissionKind kind)
|
||||
{
|
||||
// Suppressed kind means shelving ate the emission — we don't fire for subscribers
|
||||
// but the state record still advanced so startup recovery reflects reality.
|
||||
if (kind == EmissionKind.Suppressed || kind == EmissionKind.None) return;
|
||||
|
||||
var message = MessageTemplate.Resolve(state.Definition.MessageTemplate, TryLookup);
|
||||
var evt = new ScriptedAlarmEvent(
|
||||
AlarmId: state.Definition.AlarmId,
|
||||
EquipmentPath: state.Definition.EquipmentPath,
|
||||
AlarmName: state.Definition.AlarmName,
|
||||
Kind: state.Definition.Kind,
|
||||
Severity: state.Definition.Severity,
|
||||
Message: message,
|
||||
Condition: condition,
|
||||
Emission: kind,
|
||||
TimestampUtc: _clock());
|
||||
try { OnEvent?.Invoke(this, evt); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_engineLogger.Warning(ex, "ScriptedAlarmEngine OnEvent subscriber threw for {AlarmId}", state.Definition.AlarmId);
|
||||
}
|
||||
}
|
||||
|
||||
private DataValueSnapshot? TryLookup(string path)
|
||||
=> _valueCache.TryGetValue(path, out var v) ? v : null;
|
||||
|
||||
private void RunShelvingCheck()
|
||||
{
|
||||
if (_disposed) return;
|
||||
var ids = _alarms.Keys.ToArray();
|
||||
_ = ShelvingCheckAsync(ids, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task ShelvingCheckAsync(IReadOnlyList<string> alarmIds, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var now = _clock();
|
||||
foreach (var id in alarmIds)
|
||||
{
|
||||
if (!_alarms.TryGetValue(id, out var state)) continue;
|
||||
var result = Part9StateMachine.ApplyShelvingCheck(state.Condition, now);
|
||||
if (!ReferenceEquals(result.State, state.Condition))
|
||||
{
|
||||
_alarms[id] = state with { Condition = result.State };
|
||||
await _store.SaveAsync(result.State, ct).ConfigureAwait(false);
|
||||
if (result.Emission != EmissionKind.None)
|
||||
EmitEvent(state, result.State, result.Emission);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally { _evalGate.Release(); }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_engineLogger.Warning(ex, "ScriptedAlarmEngine shelving-check failed");
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromUpstream()
|
||||
{
|
||||
foreach (var s in _upstreamSubscriptions)
|
||||
{
|
||||
try { s.Dispose(); } catch { }
|
||||
}
|
||||
_upstreamSubscriptions.Clear();
|
||||
}
|
||||
|
||||
private void EnsureLoaded()
|
||||
{
|
||||
if (!_loaded) throw new InvalidOperationException(
|
||||
"ScriptedAlarmEngine not loaded. Call LoadAsync first.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_shelvingTimer?.Dispose();
|
||||
UnsubscribeFromUpstream();
|
||||
_alarms.Clear();
|
||||
_alarmsReferencing.Clear();
|
||||
}
|
||||
|
||||
private sealed record AlarmState(
|
||||
ScriptedAlarmDefinition Definition,
|
||||
TimedScriptEvaluator<AlarmPredicateContext, bool> Evaluator,
|
||||
IReadOnlySet<string> Inputs,
|
||||
IReadOnlyList<string> TemplateTokens,
|
||||
ILogger Logger,
|
||||
AlarmConditionState Condition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One alarm emission the engine pushed to subscribers. Carries everything
|
||||
/// downstream consumers (OPC UA alarm-source adapter + historian sink) need to
|
||||
/// publish the event without re-querying the engine.
|
||||
/// </summary>
|
||||
public sealed record ScriptedAlarmEvent(
|
||||
string AlarmId,
|
||||
string EquipmentPath,
|
||||
string AlarmName,
|
||||
AlarmKind Kind,
|
||||
AlarmSeverity Severity,
|
||||
string Message,
|
||||
AlarmConditionState Condition,
|
||||
EmissionKind Emission,
|
||||
DateTime TimestampUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Upstream source abstraction — intentionally identical shape to the virtual-tag
|
||||
/// engine's so Stream G can compose them behind one driver bridge.
|
||||
/// </summary>
|
||||
public interface ITagUpstreamSource
|
||||
{
|
||||
DataValueSnapshot ReadTag(string path);
|
||||
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||
}
|
||||
122
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
Normal file
122
src/ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms/ScriptedAlarmSource.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter that exposes <see cref="ScriptedAlarmEngine"/> through the driver-agnostic
|
||||
/// <see cref="IAlarmSource"/> surface. The existing Phase 6.1 <c>AlarmTracker</c>
|
||||
/// composition fan-out consumes this alongside Galaxy / AB CIP / FOCAS alarm
|
||||
/// sources — no per-source branching in the fan-out.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per Phase 7 plan Stream C.6, ack / confirm / shelve / unshelve are OPC UA
|
||||
/// method calls per-condition. This adapter implements <see cref="AcknowledgeAsync"/>
|
||||
/// from the base interface; the richer Part 9 methods (Confirm / Shelve /
|
||||
/// Unshelve / AddComment) live directly on the engine, invoked from OPC UA
|
||||
/// method handlers wired up in Stream G.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// SubscribeAlarmsAsync takes a list of source-node-id filters (typically an
|
||||
/// Equipment path prefix). When the list is empty every alarm matches. The
|
||||
/// adapter doesn't maintain per-subscription state beyond the filter set — it
|
||||
/// checks each emission against every live subscription.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarmSource : IAlarmSource, IDisposable
|
||||
{
|
||||
private readonly ScriptedAlarmEngine _engine;
|
||||
private readonly ConcurrentDictionary<string, Subscription> _subscriptions
|
||||
= new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
public ScriptedAlarmSource(ScriptedAlarmEngine engine)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_engine.OnEvent += OnEngineEvent;
|
||||
}
|
||||
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
|
||||
{
|
||||
if (sourceNodeIds is null) throw new ArgumentNullException(nameof(sourceNodeIds));
|
||||
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
|
||||
_subscriptions[handle.DiagnosticId] = new Subscription(handle,
|
||||
new HashSet<string>(sourceNodeIds, StringComparer.Ordinal));
|
||||
return Task.FromResult<IAlarmSubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
||||
_subscriptions.TryRemove(handle.DiagnosticId, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
|
||||
{
|
||||
if (acknowledgements is null) throw new ArgumentNullException(nameof(acknowledgements));
|
||||
foreach (var a in acknowledgements)
|
||||
{
|
||||
// The base interface doesn't carry a user identity — Stream G provides the
|
||||
// authenticated principal at the OPC UA dispatch layer + proxies through
|
||||
// the engine's richer AcknowledgeAsync. Here we default to "opcua-client"
|
||||
// so callers using the raw IAlarmSource still produce an audit entry.
|
||||
await _engine.AcknowledgeAsync(a.ConditionId, "opcua-client", a.Comment, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEngineEvent(object? sender, ScriptedAlarmEvent evt)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
foreach (var sub in _subscriptions.Values)
|
||||
{
|
||||
if (!Matches(sub, evt)) continue;
|
||||
var payload = new AlarmEventArgs(
|
||||
SubscriptionHandle: sub.Handle,
|
||||
SourceNodeId: evt.EquipmentPath,
|
||||
ConditionId: evt.AlarmId,
|
||||
AlarmType: evt.Kind.ToString(),
|
||||
Message: evt.Message,
|
||||
Severity: evt.Severity,
|
||||
SourceTimestampUtc: evt.TimestampUtc);
|
||||
try { OnAlarmEvent?.Invoke(this, payload); }
|
||||
catch { /* subscriber exceptions don't crash the adapter */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static bool Matches(Subscription sub, ScriptedAlarmEvent evt)
|
||||
{
|
||||
if (sub.Filter.Count == 0) return true;
|
||||
// A subscription matches if any filter is a prefix of the alarm's equipment
|
||||
// path — typical use is "Enterprise/Site/Area/Line" filtering a whole line.
|
||||
foreach (var f in sub.Filter)
|
||||
{
|
||||
if (evt.EquipmentPath.Equals(f, StringComparison.Ordinal)) return true;
|
||||
if (evt.EquipmentPath.StartsWith(f + "/", StringComparison.Ordinal)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_engine.OnEvent -= OnEngineEvent;
|
||||
_subscriptions.Clear();
|
||||
}
|
||||
|
||||
private sealed class SubscriptionHandle : IAlarmSubscriptionHandle
|
||||
{
|
||||
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||
public string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlySet<string> Filter);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
83
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs
Normal file
83
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Source-hash-keyed compile cache for user scripts. Roslyn compilation is the most
|
||||
/// expensive step in the evaluator pipeline (5-20ms per script depending on size);
|
||||
/// re-compiling on every value-change event would starve the virtual-tag engine.
|
||||
/// The cache is generic on the <see cref="ScriptContext"/> subclass + result type so
|
||||
/// different engines (virtual-tag / alarm-predicate / future alarm-action) each get
|
||||
/// their own cache instance — there's no cross-type pollution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Concurrent-safe: <see cref="ConcurrentDictionary{TKey, TValue}"/> of
|
||||
/// <see cref="Lazy{T}"/> means a miss on two threads compiles exactly once.
|
||||
/// <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> guarantees other
|
||||
/// threads block on the in-flight compile rather than racing to duplicate work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cache is keyed on SHA-256 of the UTF-8 bytes of the source — collision-free in
|
||||
/// practice. Whitespace changes therefore miss the cache on purpose; operators
|
||||
/// see re-compile time on their first evaluation after a format-only edit which
|
||||
/// is rare and benign.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No capacity bound. Virtual-tag + alarm scripts are operator-authored and
|
||||
/// bounded by config DB (typically low thousands). If that changes in v3, add an
|
||||
/// LRU eviction policy — the API stays the same.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CompiledScriptCache<TContext, TResult>
|
||||
where TContext : ScriptContext
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
|
||||
/// on first sight + reusing thereafter. If the source fails to compile, the
|
||||
/// original Roslyn / sandbox exception propagates; the cache entry is removed so
|
||||
/// the next call retries (useful during Admin UI authoring when the operator is
|
||||
/// still fixing syntax).
|
||||
/// </summary>
|
||||
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
|
||||
{
|
||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||
|
||||
var key = HashSource(scriptSource);
|
||||
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
|
||||
() => ScriptEvaluator<TContext, TResult>.Compile(scriptSource),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication));
|
||||
|
||||
try
|
||||
{
|
||||
return lazy.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Failed compile — evict so a retry with corrected source can succeed.
|
||||
_cache.TryRemove(key, out _);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
|
||||
public int Count => _cache.Count;
|
||||
|
||||
/// <summary>Drop every cached compile. Used on config generation publish + tests.</summary>
|
||||
public void Clear() => _cache.Clear();
|
||||
|
||||
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
|
||||
public bool Contains(string scriptSource)
|
||||
=> _cache.ContainsKey(HashSource(scriptSource));
|
||||
|
||||
private static string HashSource(string source)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(source);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Serilog sink that mirrors script log events at <see cref="LogEventLevel.Error"/>
|
||||
/// or higher to a companion logger (typically the main <c>opcua-*.log</c>) at
|
||||
/// <see cref="LogEventLevel.Warning"/>. Lets operators see script errors in the
|
||||
/// primary server log without drowning it in Debug/Info/Warning noise from scripts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Registered alongside the dedicated <c>scripts-*.log</c> rolling file sink in
|
||||
/// the root script-logger configuration — events below Error land only in the
|
||||
/// scripts file; Error/Fatal events land in both the scripts file (at original
|
||||
/// level) and the main log (downgraded to Warning since the main log's audience
|
||||
/// is server operators, not script authors).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The forwarded message preserves the <c>ScriptName</c> property so operators
|
||||
/// reading the main log can tell which script raised the error at a glance.
|
||||
/// Original exception (if any) is attached so the main log's diagnostics keep
|
||||
/// the full stack trace.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptLogCompanionSink : ILogEventSink
|
||||
{
|
||||
private readonly ILogger _mainLogger;
|
||||
private readonly LogEventLevel _minMirrorLevel;
|
||||
|
||||
public ScriptLogCompanionSink(ILogger mainLogger, LogEventLevel minMirrorLevel = LogEventLevel.Error)
|
||||
{
|
||||
_mainLogger = mainLogger ?? throw new ArgumentNullException(nameof(mainLogger));
|
||||
_minMirrorLevel = minMirrorLevel;
|
||||
}
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
if (logEvent is null) return;
|
||||
if (logEvent.Level < _minMirrorLevel) return;
|
||||
|
||||
var scriptName = "unknown";
|
||||
if (logEvent.Properties.TryGetValue(ScriptLoggerFactory.ScriptNameProperty, out var prop)
|
||||
&& prop is ScalarValue sv && sv.Value is string s)
|
||||
{
|
||||
scriptName = s;
|
||||
}
|
||||
|
||||
var rendered = logEvent.RenderMessage();
|
||||
if (logEvent.Exception is not null)
|
||||
{
|
||||
_mainLogger.Warning(logEvent.Exception,
|
||||
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
|
||||
scriptName, logEvent.Level, rendered);
|
||||
}
|
||||
else
|
||||
{
|
||||
_mainLogger.Warning(
|
||||
"[Script] {ScriptName} emitted {OriginalLevel}: {ScriptMessage}",
|
||||
scriptName, logEvent.Level, rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs
Normal file
48
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptLoggerFactory.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Creates per-script Serilog <see cref="ILogger"/> instances with the
|
||||
/// <c>ScriptName</c> structured property pre-bound. Every log call from a user
|
||||
/// script carries the owning virtual-tag or alarm name so operators can filter the
|
||||
/// dedicated <c>scripts-*.log</c> sink by script in the Admin UI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Factory-based — the engine (Stream B / C) constructs exactly one instance
|
||||
/// from the root script-logger pipeline at startup, then derives a per-script
|
||||
/// logger for each <see cref="ScriptContext"/> it builds. No per-evaluation
|
||||
/// allocation in the hot path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The wrapped root logger is responsible for output wiring — typically a
|
||||
/// rolling file sink to <c>scripts-*.log</c> plus a
|
||||
/// <see cref="ScriptLogCompanionSink"/> that forwards Error-or-higher events
|
||||
/// to the main server log at Warning level so operators see script errors
|
||||
/// in the primary log without drowning it in Info noise.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptLoggerFactory
|
||||
{
|
||||
/// <summary>Structured property name the enricher binds. Stable for log filtering.</summary>
|
||||
public const string ScriptNameProperty = "ScriptName";
|
||||
|
||||
private readonly ILogger _rootLogger;
|
||||
|
||||
public ScriptLoggerFactory(ILogger rootLogger)
|
||||
{
|
||||
_rootLogger = rootLogger ?? throw new ArgumentNullException(nameof(rootLogger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a per-script logger. Every event it emits carries
|
||||
/// <c>ScriptName=<paramref name="scriptName"/></c> as a structured property.
|
||||
/// </summary>
|
||||
public ILogger Create(string scriptName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptName))
|
||||
throw new ArgumentException("Script name is required.", nameof(scriptName));
|
||||
return _rootLogger.ForContext(ScriptNameProperty, scriptName);
|
||||
}
|
||||
}
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="ScriptEvaluator{TContext, TResult}"/> with a per-evaluation
|
||||
/// wall-clock timeout. Default is 250ms per Phase 7 plan Stream A.4; configurable
|
||||
/// per tag so deployments with slower backends can widen it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implemented with <see cref="Task.WaitAsync(TimeSpan, CancellationToken)"/>
|
||||
/// rather than a cancellation-token-only approach because Roslyn-compiled
|
||||
/// scripts don't internally poll the cancellation token unless the user code
|
||||
/// does async work. A CPU-bound infinite loop in a script won't honor a
|
||||
/// cooperative cancel — <c>WaitAsync</c> returns control when the timeout fires
|
||||
/// regardless of whether the inner task completes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Known limitation:</b> when a script times out, the underlying ScriptRunner
|
||||
/// task continues running on a thread-pool thread until the Roslyn runtime
|
||||
/// returns. In the CPU-bound-infinite-loop case that's effectively "leaked" —
|
||||
/// the thread is tied up until the runtime decides to return, which it may
|
||||
/// never do. Phase 7 plan Stream A.4 accepts this as a known trade-off; tighter
|
||||
/// CPU budgeting would require an out-of-process script runner, which is a v3
|
||||
/// concern. In practice, the timeout + structured warning log surfaces the
|
||||
/// offending script so the operator can fix it; the orphan thread is rare.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Caller-supplied <see cref="CancellationToken"/> is honored — if the caller
|
||||
/// cancels before the timeout fires, the caller's cancel wins and the
|
||||
/// <see cref="OperationCanceledException"/> propagates (not wrapped as
|
||||
/// <see cref="ScriptTimeoutException"/>). That distinction matters: the
|
||||
/// virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't
|
||||
/// see those as timeouts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||
where TContext : ScriptContext
|
||||
{
|
||||
/// <summary>Default timeout per Phase 7 plan Stream A.4 — 250ms.</summary>
|
||||
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
private readonly ScriptEvaluator<TContext, TResult> _inner;
|
||||
|
||||
/// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
|
||||
public TimeSpan Timeout { get; }
|
||||
|
||||
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
|
||||
: this(inner, DefaultTimeout)
|
||||
{
|
||||
}
|
||||
|
||||
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner, TimeSpan timeout)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive.");
|
||||
Timeout = timeout;
|
||||
}
|
||||
|
||||
public async Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||
|
||||
// Push evaluation to a thread-pool thread so a CPU-bound script (e.g. a tight
|
||||
// loop with no async work) doesn't hog the caller's thread before WaitAsync
|
||||
// gets to register its timeout. Without this, Roslyn's ScriptRunner executes
|
||||
// synchronously on the calling thread and returns an already-completed Task,
|
||||
// so WaitAsync sees a completed task and never fires the timeout.
|
||||
var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct);
|
||||
try
|
||||
{
|
||||
return await runTask.WaitAsync(Timeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// WaitAsync's synthesized timeout — the inner task may still be running
|
||||
// on its thread-pool thread (known leak documented in the class summary).
|
||||
// Wrap so callers can distinguish from user-written timeout logic.
|
||||
throw new ScriptTimeoutException(Timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
|
||||
/// engine (Stream B) catches this + maps the owning tag's quality to
|
||||
/// <c>BadInternalError</c> per Phase 7 plan decision #11, logging a structured
|
||||
/// warning with the offending script name so operators can locate + fix it.
|
||||
/// </summary>
|
||||
public sealed class ScriptTimeoutException : Exception
|
||||
{
|
||||
public TimeSpan Timeout { get; }
|
||||
|
||||
public ScriptTimeoutException(TimeSpan timeout)
|
||||
: base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " +
|
||||
"The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " +
|
||||
"around the timeout and consider widening the timeout per tag, simplifying the script, or " +
|
||||
"moving heavy work out of the evaluation path.")
|
||||
{
|
||||
Timeout = timeout;
|
||||
}
|
||||
}
|
||||
271
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs
Normal file
271
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/DependencyGraph.cs
Normal file
@@ -0,0 +1,271 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Directed dependency graph over tag paths. Nodes are tag paths (either driver
|
||||
/// tags — leaves — or virtual tags — internal nodes). Edges run from a virtual tag
|
||||
/// to each tag it reads via <c>ctx.GetTag(...)</c>. Supports cycle detection at
|
||||
/// publish time and topological sort for evaluation ordering.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Cycle detection uses Tarjan's strongly-connected-components algorithm,
|
||||
/// iterative implementation (no recursion) so deeply-nested graphs can't blow
|
||||
/// the stack. A cycle of length > 1 (or a self-loop) is a publish-time error;
|
||||
/// the engine refuses to load such a config.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Topological sort uses Kahn's algorithm. The output order guarantees that when
|
||||
/// tag X depends on tag Y, Y appears before X — so a change cascade starting at
|
||||
/// Y can evaluate the full downstream closure in one serial pass without needing
|
||||
/// a second iteration.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Missing leaf dependencies (a virtual tag reads a driver tag that doesn't
|
||||
/// exist in the live config) are NOT rejected here — the graph treats any
|
||||
/// unregistered path as an implicit leaf. Leaf validity is a separate concern
|
||||
/// handled at engine-load time against the authoritative tag catalog.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DependencyGraph
|
||||
{
|
||||
private readonly Dictionary<string, HashSet<string>> _dependsOn = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HashSet<string>> _dependents = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Register a node and the set of tags it depends on. Idempotent — re-adding
|
||||
/// the same node overwrites the prior dependency set, so re-publishing an edited
|
||||
/// script works without a separate "remove" call.
|
||||
/// </summary>
|
||||
public void Add(string nodeId, IReadOnlySet<string> dependsOn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nodeId)) throw new ArgumentException("Node id required.", nameof(nodeId));
|
||||
if (dependsOn is null) throw new ArgumentNullException(nameof(dependsOn));
|
||||
|
||||
// Remove any prior dependents pointing at the previous version of this node.
|
||||
if (_dependsOn.TryGetValue(nodeId, out var previous))
|
||||
{
|
||||
foreach (var dep in previous)
|
||||
{
|
||||
if (_dependents.TryGetValue(dep, out var set))
|
||||
set.Remove(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
_dependsOn[nodeId] = new HashSet<string>(dependsOn, StringComparer.Ordinal);
|
||||
foreach (var dep in dependsOn)
|
||||
{
|
||||
if (!_dependents.TryGetValue(dep, out var set))
|
||||
_dependents[dep] = set = new HashSet<string>(StringComparer.Ordinal);
|
||||
set.Add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tag paths <paramref name="nodeId"/> directly reads.</summary>
|
||||
public IReadOnlySet<string> DirectDependencies(string nodeId) =>
|
||||
_dependsOn.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Tags whose evaluation depends on <paramref name="nodeId"/> — i.e. when
|
||||
/// <paramref name="nodeId"/> changes, these need to re-evaluate. Direct only;
|
||||
/// transitive propagation falls out of the topological sort.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string> DirectDependents(string nodeId) =>
|
||||
_dependents.TryGetValue(nodeId, out var set) ? set : (IReadOnlySet<string>)new HashSet<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Full transitive dependent closure of <paramref name="nodeId"/> in topological
|
||||
/// order (direct dependents first, then their dependents, and so on). Used by the
|
||||
/// change-trigger dispatcher to schedule the right sequence of re-evaluations
|
||||
/// when a single upstream value changes.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> TransitiveDependentsInOrder(string nodeId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nodeId)) return [];
|
||||
|
||||
var result = new List<string>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var order = TopologicalSort();
|
||||
var rank = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < order.Count; i++) rank[order[i]] = i;
|
||||
|
||||
// DFS from the changed node collecting every reachable dependent.
|
||||
var stack = new Stack<string>();
|
||||
stack.Push(nodeId);
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var cur = stack.Pop();
|
||||
foreach (var dep in DirectDependents(cur))
|
||||
{
|
||||
if (visited.Add(dep))
|
||||
{
|
||||
result.Add(dep);
|
||||
stack.Push(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by topological rank so when re-evaluation runs serial, earlier entries
|
||||
// are computed before later entries that might depend on them.
|
||||
result.Sort((a, b) =>
|
||||
{
|
||||
var ra = rank.TryGetValue(a, out var va) ? va : int.MaxValue;
|
||||
var rb = rank.TryGetValue(b, out var vb) ? vb : int.MaxValue;
|
||||
return ra.CompareTo(rb);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Iterable of every registered node id (inputs-only tags excluded).</summary>
|
||||
public IReadOnlyCollection<string> RegisteredNodes => _dependsOn.Keys;
|
||||
|
||||
/// <summary>
|
||||
/// Produce an evaluation order where every node appears after all its
|
||||
/// dependencies. Throws <see cref="DependencyCycleException"/> if any cycle
|
||||
/// exists. Implemented via Kahn's algorithm.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> TopologicalSort()
|
||||
{
|
||||
// Kahn's framing: edge u -> v means "u must come before v". For dependencies,
|
||||
// if X depends on Y, Y must come before X, so the edge runs Y -> X and X has
|
||||
// an incoming edge from Y. inDegree[X] = count of X's registered (virtual) deps
|
||||
// — leaf driver-tag deps don't contribute to ordering since they're never emitted.
|
||||
var inDegree = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var node in _dependsOn.Keys) inDegree[node] = 0;
|
||||
foreach (var kv in _dependsOn)
|
||||
{
|
||||
var nodeId = kv.Key;
|
||||
foreach (var dep in kv.Value)
|
||||
{
|
||||
if (_dependsOn.ContainsKey(dep))
|
||||
inDegree[nodeId]++;
|
||||
}
|
||||
}
|
||||
|
||||
var ready = new Queue<string>(inDegree.Where(kv => kv.Value == 0).Select(kv => kv.Key));
|
||||
var result = new List<string>();
|
||||
while (ready.Count > 0)
|
||||
{
|
||||
var n = ready.Dequeue();
|
||||
result.Add(n);
|
||||
// In our edge direction (node -> deps), removing n means decrementing in-degree
|
||||
// of every node that DEPENDS on n.
|
||||
foreach (var dependent in DirectDependents(n))
|
||||
{
|
||||
if (inDegree.TryGetValue(dependent, out var d))
|
||||
{
|
||||
inDegree[dependent] = d - 1;
|
||||
if (inDegree[dependent] == 0) ready.Enqueue(dependent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Count != inDegree.Count)
|
||||
{
|
||||
var cycles = DetectCycles();
|
||||
throw new DependencyCycleException(cycles);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns every strongly-connected component of size > 1 + every self-loop.
|
||||
/// Empty list means the graph is a DAG. Useful for surfacing every cycle in one
|
||||
/// rejection pass so operators see all of them, not just one at a time.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyList<string>> DetectCycles()
|
||||
{
|
||||
// Iterative Tarjan's SCC. Avoids recursion so deep graphs don't StackOverflow.
|
||||
var index = 0;
|
||||
var indexOf = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var lowlinkOf = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var onStack = new HashSet<string>(StringComparer.Ordinal);
|
||||
var sccStack = new Stack<string>();
|
||||
var cycles = new List<IReadOnlyList<string>>();
|
||||
|
||||
foreach (var root in _dependsOn.Keys)
|
||||
{
|
||||
if (indexOf.ContainsKey(root)) continue;
|
||||
|
||||
var work = new Stack<(string node, IEnumerator<string> iter)>();
|
||||
indexOf[root] = index;
|
||||
lowlinkOf[root] = index;
|
||||
index++;
|
||||
onStack.Add(root);
|
||||
sccStack.Push(root);
|
||||
work.Push((root, _dependsOn[root].GetEnumerator()));
|
||||
|
||||
while (work.Count > 0)
|
||||
{
|
||||
var (v, iter) = work.Peek();
|
||||
if (iter.MoveNext())
|
||||
{
|
||||
var w = iter.Current;
|
||||
if (!_dependsOn.ContainsKey(w))
|
||||
continue; // leaf — not part of any cycle with us
|
||||
if (!indexOf.ContainsKey(w))
|
||||
{
|
||||
indexOf[w] = index;
|
||||
lowlinkOf[w] = index;
|
||||
index++;
|
||||
onStack.Add(w);
|
||||
sccStack.Push(w);
|
||||
work.Push((w, _dependsOn[w].GetEnumerator()));
|
||||
}
|
||||
else if (onStack.Contains(w))
|
||||
{
|
||||
lowlinkOf[v] = Math.Min(lowlinkOf[v], indexOf[w]);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// v fully explored — unwind
|
||||
work.Pop();
|
||||
if (lowlinkOf[v] == indexOf[v])
|
||||
{
|
||||
var component = new List<string>();
|
||||
string w;
|
||||
do
|
||||
{
|
||||
w = sccStack.Pop();
|
||||
onStack.Remove(w);
|
||||
component.Add(w);
|
||||
} while (w != v);
|
||||
|
||||
if (component.Count > 1 || _dependsOn[v].Contains(v))
|
||||
cycles.Add(component);
|
||||
}
|
||||
else if (work.Count > 0)
|
||||
{
|
||||
var parent = work.Peek().node;
|
||||
lowlinkOf[parent] = Math.Min(lowlinkOf[parent], lowlinkOf[v]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cycles;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_dependsOn.Clear();
|
||||
_dependents.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thrown when <see cref="DependencyGraph.TopologicalSort"/> finds one or more cycles.</summary>
|
||||
public sealed class DependencyCycleException : Exception
|
||||
{
|
||||
public IReadOnlyList<IReadOnlyList<string>> Cycles { get; }
|
||||
|
||||
public DependencyCycleException(IReadOnlyList<IReadOnlyList<string>> cycles)
|
||||
: base(BuildMessage(cycles))
|
||||
{
|
||||
Cycles = cycles;
|
||||
}
|
||||
|
||||
private static string BuildMessage(IReadOnlyList<IReadOnlyList<string>> cycles)
|
||||
{
|
||||
var lines = cycles.Select(c => " - " + string.Join(" -> ", c) + " -> " + c[0]);
|
||||
return "Virtual-tag dependency graph contains cycle(s):\n" + string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
25
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Sink for virtual-tag evaluation results that the operator marked
|
||||
/// <c>Historize = true</c>. Stream G wires this to the existing history-write path
|
||||
/// drivers use; tests inject a fake recorder.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Emission is fire-and-forget from the evaluation pipeline — a slow historian must
|
||||
/// not block script evaluations. Implementations queue internally and drain on their
|
||||
/// own cadence.
|
||||
/// </remarks>
|
||||
public interface IHistoryWriter
|
||||
{
|
||||
void Record(string path, DataValueSnapshot value);
|
||||
}
|
||||
|
||||
/// <summary>No-op default used when no historian is configured.</summary>
|
||||
public sealed class NullHistoryWriter : IHistoryWriter
|
||||
{
|
||||
public static readonly NullHistoryWriter Instance = new();
|
||||
public void Record(string path, DataValueSnapshot value) { }
|
||||
}
|
||||
40
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs
Normal file
40
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// What the virtual-tag engine pulls driver-tag values from. Implementations
|
||||
/// shipped in Stream G bridge this to <see cref="IReadable"/> + <see cref="ISubscribable"/>
|
||||
/// on the live driver instances; tests use an in-memory fake.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The read path is synchronous because user scripts call
|
||||
/// <c>ctx.GetTag(path)</c> inline — blocking on a driver wire call per-script
|
||||
/// evaluation would kill throughput. Implementations are expected to serve
|
||||
/// from a last-known-value cache populated by the subscription callbacks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The subscription path feeds the engine's <c>ChangeTriggerDispatcher</c> so
|
||||
/// change-driven virtual tags re-evaluate on any upstream delta (value, status,
|
||||
/// or timestamp). One subscription per distinct upstream tag path; the engine
|
||||
/// tracks the mapping itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ITagUpstreamSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Synchronous read returning the last-known value + quality for
|
||||
/// <paramref name="path"/>. Returns a <c>BadNodeIdUnknown</c>-quality snapshot
|
||||
/// when the path isn't configured.
|
||||
/// </summary>
|
||||
DataValueSnapshot ReadTag(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Register an observer that fires every time the upstream value at
|
||||
/// <paramref name="path"/> changes. Returns an <see cref="IDisposable"/> the
|
||||
/// engine disposes when the virtual-tag config is reloaded or the engine shuts
|
||||
/// down, so source-side subscriptions don't leak.
|
||||
/// </summary>
|
||||
IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Periodic re-evaluation scheduler for tags with a non-null
|
||||
/// <see cref="VirtualTagDefinition.TimerInterval"/>. Independent of the
|
||||
/// change-trigger path — a tag can be timer-only, change-only, or both. One
|
||||
/// <see cref="System.Threading.Timer"/> per interval-group keeps the wire count
|
||||
/// low regardless of tag count.
|
||||
/// </summary>
|
||||
public sealed class TimerTriggerScheduler : IDisposable
|
||||
{
|
||||
private readonly VirtualTagEngine _engine;
|
||||
private readonly ILogger _logger;
|
||||
private readonly List<Timer> _timers = [];
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private bool _disposed;
|
||||
|
||||
public TimerTriggerScheduler(VirtualTagEngine engine, ILogger logger)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stand up one <see cref="Timer"/> per unique interval. All tags with
|
||||
/// matching interval share a timer; each tick triggers re-evaluation of the
|
||||
/// group in topological order so cascades are consistent with change-triggered
|
||||
/// behavior.
|
||||
/// </summary>
|
||||
public void Start(IReadOnlyList<VirtualTagDefinition> definitions)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(TimerTriggerScheduler));
|
||||
|
||||
var byInterval = definitions
|
||||
.Where(d => d.TimerInterval.HasValue && d.TimerInterval.Value > TimeSpan.Zero)
|
||||
.GroupBy(d => d.TimerInterval!.Value);
|
||||
|
||||
foreach (var group in byInterval)
|
||||
{
|
||||
var paths = group.Select(d => d.Path).ToArray();
|
||||
var interval = group.Key;
|
||||
var timer = new Timer(_ => Tick(paths), null, interval, interval);
|
||||
_timers.Add(timer);
|
||||
_logger.Information("TimerTriggerScheduler: {TagCount} tag(s) on {Interval} cadence",
|
||||
paths.Length, interval);
|
||||
}
|
||||
}
|
||||
|
||||
private void Tick(IReadOnlyList<string> paths)
|
||||
{
|
||||
if (_cts.IsCancellationRequested) return;
|
||||
foreach (var p in paths)
|
||||
{
|
||||
try
|
||||
{
|
||||
_engine.EvaluateOneAsync(p, _cts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "TimerTriggerScheduler evaluate failed for {Path}", p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cts.Cancel();
|
||||
foreach (var t in _timers)
|
||||
{
|
||||
try { t.Dispose(); } catch { }
|
||||
}
|
||||
_timers.Clear();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagContext.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come
|
||||
/// out of the engine's last-known-value cache (driver tags updated via the
|
||||
/// <see cref="ITagUpstreamSource"/> subscription, virtual tags updated by prior
|
||||
/// evaluations). Writes route through the engine's <c>SetVirtualTag</c> callback so
|
||||
/// cross-tag write side effects still participate in change-trigger cascades.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Context instances are evaluation-scoped, not tag-scoped. The engine
|
||||
/// constructs a fresh context for every run — cheap because the constructor
|
||||
/// just captures references — so scripts can't cache mutable state across runs
|
||||
/// via <c>ctx</c>. Mutable state across runs is a future decision (e.g. a
|
||||
/// dedicated <c>ctx.Memory</c> dictionary); not in scope for Phase 7.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="Now"/> clock is injectable so tests can pin time
|
||||
/// deterministically. Production wires to <see cref="DateTime.UtcNow"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VirtualTagContext : ScriptContext
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
|
||||
private readonly Action<string, object?> _setVirtualTag;
|
||||
private readonly Func<DateTime> _clock;
|
||||
|
||||
public VirtualTagContext(
|
||||
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
|
||||
Action<string, object?> setVirtualTag,
|
||||
ILogger logger,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
|
||||
_setVirtualTag = setVirtualTag ?? throw new ArgumentNullException(nameof(setVirtualTag));
|
||||
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||
return _readCache.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||
}
|
||||
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
throw new ArgumentException("Virtual tag path required.", nameof(path));
|
||||
_setVirtualTag(path, value);
|
||||
}
|
||||
|
||||
public override DateTime Now => _clock();
|
||||
|
||||
public override ILogger Logger { get; }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Operator-authored virtual-tag configuration row. Phase 7 Stream E (config DB
|
||||
/// schema) materializes these from the <c>VirtualTag</c> + <c>Script</c> tables on
|
||||
/// publish; the engine ingests a list of them at load time.
|
||||
/// </summary>
|
||||
/// <param name="Path">
|
||||
/// UNS tag path — <c>Enterprise/Site/Area/Line/Equipment/TagName</c>. Used both as
|
||||
/// the engine's internal id and the OPC UA browse path.
|
||||
/// </param>
|
||||
/// <param name="DataType">
|
||||
/// Expected return type. The evaluator coerces the script's return value to this
|
||||
/// type before publishing; mismatch surfaces as <c>BadTypeMismatch</c> quality on
|
||||
/// the tag.
|
||||
/// </param>
|
||||
/// <param name="ScriptSource">Roslyn C# script source. Must compile under <c>ScriptSandbox</c>.</param>
|
||||
/// <param name="ChangeTriggered">
|
||||
/// True if any input tag's change (value / status / timestamp delta) should trigger
|
||||
/// re-evaluation. Operator picks per tag — usually true for inputs that change at
|
||||
/// protocol rates.
|
||||
/// </param>
|
||||
/// <param name="TimerInterval">
|
||||
/// Optional periodic re-evaluation cadence. Null = timer-driven disabled. Both can
|
||||
/// be enabled simultaneously; independent scheduling paths both feed
|
||||
/// <c>EvaluationPipeline</c>.
|
||||
/// </param>
|
||||
/// <param name="Historize">
|
||||
/// When true, every evaluation result is forwarded to the configured
|
||||
/// <see cref="IHistoryWriter"/>. Operator-set per tag; the Admin UI exposes as a
|
||||
/// checkbox.
|
||||
/// </param>
|
||||
public sealed record VirtualTagDefinition(
|
||||
string Path,
|
||||
DriverDataType DataType,
|
||||
string ScriptSource,
|
||||
bool ChangeTriggered = true,
|
||||
TimeSpan? TimerInterval = null,
|
||||
bool Historize = false);
|
||||
385
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs
Normal file
385
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagEngine.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// The Phase 7 virtual-tag evaluation engine. Ingests a set of
|
||||
/// <see cref="VirtualTagDefinition"/>s at load time, compiles each script against
|
||||
/// <see cref="ScriptSandbox"/>, builds the dependency graph, subscribes to every
|
||||
/// referenced upstream tag, and schedules re-evaluations on change + on timer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Evaluation order is topological per ADR-001 / Phase 7 plan decision #19 —
|
||||
/// serial for the v1 rollout, parallel promoted to a follow-up. When upstream
|
||||
/// tag X changes, the engine computes the transitive dependent closure of X in
|
||||
/// topological rank and evaluates each in turn, so a cascade through multiple
|
||||
/// levels of virtual tags settles within one change-trigger pass.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per-tag error isolation per Phase 7 plan decision #11 — a script exception
|
||||
/// (or timeout) fails that tag's latest value with <c>BadInternalError</c> or
|
||||
/// <c>BadTypeMismatch</c> quality and logs a structured error; every other tag
|
||||
/// keeps evaluating. The engine itself never faults from a user script.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VirtualTagEngine : IDisposable
|
||||
{
|
||||
private readonly ITagUpstreamSource _upstream;
|
||||
private readonly IHistoryWriter _history;
|
||||
private readonly ScriptLoggerFactory _loggerFactory;
|
||||
private readonly ILogger _engineLogger;
|
||||
private readonly Func<DateTime> _clock;
|
||||
private readonly TimeSpan _scriptTimeout;
|
||||
|
||||
private readonly DependencyGraph _graph = new();
|
||||
private readonly Dictionary<string, VirtualTagState> _tags = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _valueCache = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _observers
|
||||
= new(StringComparer.Ordinal);
|
||||
private readonly List<IDisposable> _upstreamSubscriptions = [];
|
||||
private readonly SemaphoreSlim _evalGate = new(1, 1);
|
||||
private bool _loaded;
|
||||
private bool _disposed;
|
||||
|
||||
public VirtualTagEngine(
|
||||
ITagUpstreamSource upstream,
|
||||
ScriptLoggerFactory loggerFactory,
|
||||
ILogger engineLogger,
|
||||
IHistoryWriter? historyWriter = null,
|
||||
Func<DateTime>? clock = null,
|
||||
TimeSpan? scriptTimeout = null)
|
||||
{
|
||||
_upstream = upstream ?? throw new ArgumentNullException(nameof(upstream));
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
_engineLogger = engineLogger ?? throw new ArgumentNullException(nameof(engineLogger));
|
||||
_history = historyWriter ?? NullHistoryWriter.Instance;
|
||||
_clock = clock ?? (() => DateTime.UtcNow);
|
||||
_scriptTimeout = scriptTimeout ?? TimedScriptEvaluator<VirtualTagContext, object?>.DefaultTimeout;
|
||||
}
|
||||
|
||||
/// <summary>Registered tag paths, in topological order. Empty before <see cref="Load"/>.</summary>
|
||||
public IReadOnlyCollection<string> LoadedTagPaths => _tags.Keys;
|
||||
|
||||
/// <summary>Compile + register every tag in <paramref name="definitions"/>. Throws on cycle or any compile failure.</summary>
|
||||
public void Load(IReadOnlyList<VirtualTagDefinition> definitions)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(VirtualTagEngine));
|
||||
if (definitions is null) throw new ArgumentNullException(nameof(definitions));
|
||||
|
||||
// Start from a clean slate — supports config-publish reloads.
|
||||
UnsubscribeFromUpstream();
|
||||
_tags.Clear();
|
||||
_graph.Clear();
|
||||
|
||||
var compileFailures = new List<string>();
|
||||
foreach (var def in definitions)
|
||||
{
|
||||
try
|
||||
{
|
||||
var extraction = DependencyExtractor.Extract(def.ScriptSource);
|
||||
if (!extraction.IsValid)
|
||||
{
|
||||
var msgs = string.Join("; ", extraction.Rejections.Select(r => r.Message));
|
||||
compileFailures.Add($"{def.Path}: dependency extraction rejected — {msgs}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var evaluator = ScriptEvaluator<VirtualTagContext, object?>.Compile(def.ScriptSource);
|
||||
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _scriptTimeout);
|
||||
var scriptLogger = _loggerFactory.Create(def.Path);
|
||||
|
||||
_tags[def.Path] = new VirtualTagState(def, timed, extraction.Reads, extraction.Writes, scriptLogger);
|
||||
_graph.Add(def.Path, extraction.Reads);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
compileFailures.Add($"{def.Path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (compileFailures.Count > 0)
|
||||
{
|
||||
var joined = string.Join("\n ", compileFailures);
|
||||
throw new InvalidOperationException(
|
||||
$"Virtual-tag engine load failed. {compileFailures.Count} script(s) did not compile:\n {joined}");
|
||||
}
|
||||
|
||||
// Cycle check — throws DependencyCycleException on offense.
|
||||
_ = _graph.TopologicalSort();
|
||||
|
||||
// Subscribe to every referenced upstream path (driver tags only — virtual tags
|
||||
// cascade internally). Seed the cache with current upstream values so first
|
||||
// evaluations see something real.
|
||||
var upstreamPaths = definitions
|
||||
.SelectMany(d => _tags[d.Path].Reads)
|
||||
.Where(p => !_tags.ContainsKey(p))
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
foreach (var path in upstreamPaths)
|
||||
{
|
||||
_valueCache[path] = _upstream.ReadTag(path);
|
||||
_upstreamSubscriptions.Add(_upstream.SubscribeTag(path, OnUpstreamChange));
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
_engineLogger.Information(
|
||||
"VirtualTagEngine loaded {TagCount} tag(s), {UpstreamCount} upstream subscription(s)",
|
||||
_tags.Count, _upstreamSubscriptions.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate every registered tag once in topological order — used at startup so
|
||||
/// virtual tags have a defined initial value rather than inheriting the cache
|
||||
/// default. Also called after a config reload.
|
||||
/// </summary>
|
||||
public async Task EvaluateAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
EnsureLoaded();
|
||||
var order = _graph.TopologicalSort();
|
||||
foreach (var path in order)
|
||||
{
|
||||
if (_tags.ContainsKey(path))
|
||||
await EvaluateOneAsync(path, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Evaluate a single tag — used by the timer trigger + test hooks.</summary>
|
||||
public Task EvaluateOneAsync(string path, CancellationToken ct = default)
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (!_tags.ContainsKey(path))
|
||||
throw new ArgumentException($"Not a registered virtual tag: {path}", nameof(path));
|
||||
return EvaluateInternalAsync(path, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the most recently evaluated value for <paramref name="path"/>. Driver
|
||||
/// tags return the last-known upstream value; virtual tags return their last
|
||||
/// evaluation result.
|
||||
/// </summary>
|
||||
public DataValueSnapshot Read(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
|
||||
return _valueCache.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register an observer that fires on every evaluation of the given tag.
|
||||
/// Returns an <see cref="IDisposable"/> to unsubscribe. Does NOT fire a seed
|
||||
/// value — subscribers call <see cref="Read"/> for the current value if needed.
|
||||
/// </summary>
|
||||
public IDisposable Subscribe(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
var list = _observers.GetOrAdd(path, _ => []);
|
||||
lock (list) { list.Add(observer); }
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Change-trigger entry point — called by the upstream subscription callback.
|
||||
/// Updates the cache, fans out to observers (so OPC UA clients see the upstream
|
||||
/// change too if they subscribed via the engine), and schedules every
|
||||
/// change-triggered dependent for re-evaluation in topological order.
|
||||
/// </summary>
|
||||
internal void OnUpstreamChange(string path, DataValueSnapshot value)
|
||||
{
|
||||
_valueCache[path] = value;
|
||||
NotifyObservers(path, value);
|
||||
|
||||
// Fire-and-forget — the upstream subscription callback must not block the
|
||||
// driver's dispatcher. Exceptions during cascade are handled per-tag inside
|
||||
// EvaluateInternalAsync.
|
||||
_ = CascadeAsync(path, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task CascadeAsync(string upstreamPath, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dependents = _graph.TransitiveDependentsInOrder(upstreamPath);
|
||||
foreach (var dep in dependents)
|
||||
{
|
||||
if (_tags.TryGetValue(dep, out var state) && state.Definition.ChangeTriggered)
|
||||
await EvaluateInternalAsync(dep, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_engineLogger.Error(ex, "VirtualTagEngine cascade failed for upstream {Path}", upstreamPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EvaluateInternalAsync(string path, CancellationToken ct)
|
||||
{
|
||||
if (!_tags.TryGetValue(path, out var state)) return;
|
||||
|
||||
// Serial evaluation across all tags. Phase 7 plan decision #19 — parallel is a
|
||||
// follow-up. The semaphore bounds the evaluation graph so two cascades don't
|
||||
// interleave, which would break the "earlier nodes computed first" invariant.
|
||||
// SemaphoreSlim.WaitAsync is async-safe where Monitor.Enter is not (Monitor
|
||||
// ownership is thread-local and lost across await).
|
||||
await _evalGate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var ctxCache = BuildReadCache(state.Reads);
|
||||
var context = new VirtualTagContext(
|
||||
ctxCache,
|
||||
(p, v) => OnScriptSetVirtualTag(p, v),
|
||||
state.Logger,
|
||||
_clock);
|
||||
|
||||
DataValueSnapshot result;
|
||||
try
|
||||
{
|
||||
var raw = await state.Evaluator.RunAsync(context, ct).ConfigureAwait(false);
|
||||
var coerced = CoerceResult(raw, state.Definition.DataType);
|
||||
result = new DataValueSnapshot(coerced, 0u, _clock(), _clock());
|
||||
}
|
||||
catch (ScriptTimeoutException tex)
|
||||
{
|
||||
state.Logger.Warning("Script timed out after {Timeout}", tex.Timeout);
|
||||
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw; // shutdown path — don't misclassify
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
state.Logger.Error(ex, "Virtual-tag script threw");
|
||||
result = new DataValueSnapshot(null, 0x80020000u /* BadInternalError */, null, _clock());
|
||||
}
|
||||
|
||||
_valueCache[path] = result;
|
||||
NotifyObservers(path, result);
|
||||
if (state.Definition.Historize) _history.Record(path, result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_evalGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(IReadOnlySet<string> reads)
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var r in reads)
|
||||
{
|
||||
map[r] = _valueCache.TryGetValue(r, out var v)
|
||||
? v
|
||||
: _upstream.ReadTag(r);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void OnScriptSetVirtualTag(string path, object? value)
|
||||
{
|
||||
if (!_tags.ContainsKey(path))
|
||||
{
|
||||
_engineLogger.Warning(
|
||||
"Script attempted ctx.SetVirtualTag on non-virtual or non-registered path {Path}", path);
|
||||
return;
|
||||
}
|
||||
var snap = new DataValueSnapshot(value, 0u, _clock(), _clock());
|
||||
_valueCache[path] = snap;
|
||||
NotifyObservers(path, snap);
|
||||
if (_tags[path].Definition.Historize) _history.Record(path, snap);
|
||||
}
|
||||
|
||||
private void NotifyObservers(string path, DataValueSnapshot value)
|
||||
{
|
||||
if (!_observers.TryGetValue(path, out var list)) return;
|
||||
Action<string, DataValueSnapshot>[] snapshot;
|
||||
lock (list) { snapshot = list.ToArray(); }
|
||||
foreach (var obs in snapshot)
|
||||
{
|
||||
try { obs(path, value); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_engineLogger.Warning(ex, "Virtual-tag observer for {Path} threw", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static object? CoerceResult(object? raw, DriverDataType target)
|
||||
{
|
||||
if (raw is null) return null;
|
||||
try
|
||||
{
|
||||
return target switch
|
||||
{
|
||||
DriverDataType.Boolean => Convert.ToBoolean(raw),
|
||||
DriverDataType.Int32 => Convert.ToInt32(raw),
|
||||
DriverDataType.Int64 => Convert.ToInt64(raw),
|
||||
DriverDataType.Float32 => Convert.ToSingle(raw),
|
||||
DriverDataType.Float64 => Convert.ToDouble(raw),
|
||||
DriverDataType.String => Convert.ToString(raw) ?? string.Empty,
|
||||
DriverDataType.DateTime => raw is DateTime dt ? dt : Convert.ToDateTime(raw),
|
||||
_ => raw,
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Caller logs + maps to BadTypeMismatch — we let null propagate so the
|
||||
// outer evaluation path sets the Bad quality.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsubscribeFromUpstream()
|
||||
{
|
||||
foreach (var s in _upstreamSubscriptions)
|
||||
{
|
||||
try { s.Dispose(); } catch { /* best effort */ }
|
||||
}
|
||||
_upstreamSubscriptions.Clear();
|
||||
}
|
||||
|
||||
private void EnsureLoaded()
|
||||
{
|
||||
if (!_loaded) throw new InvalidOperationException(
|
||||
"VirtualTagEngine not loaded. Call Load(definitions) first.");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
UnsubscribeFromUpstream();
|
||||
_tags.Clear();
|
||||
_graph.Clear();
|
||||
}
|
||||
|
||||
internal DependencyGraph GraphForTesting => _graph;
|
||||
|
||||
private sealed class Unsub : IDisposable
|
||||
{
|
||||
private readonly VirtualTagEngine _engine;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
public Unsub(VirtualTagEngine e, string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
_engine = e; _path = path; _observer = observer;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
if (_engine._observers.TryGetValue(_path, out var list))
|
||||
{
|
||||
lock (list) { list.Remove(_observer); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record VirtualTagState(
|
||||
VirtualTagDefinition Definition,
|
||||
TimedScriptEvaluator<VirtualTagContext, object?> Evaluator,
|
||||
IReadOnlySet<string> Reads,
|
||||
IReadOnlySet<string> Writes,
|
||||
ILogger Logger);
|
||||
}
|
||||
89
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs
Normal file
89
src/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/VirtualTagSource.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the driver-agnostic capability surface the
|
||||
/// <c>DriverNodeManager</c> dispatches to when a node resolves to
|
||||
/// <c>NodeSource.Virtual</c> per ADR-002. Reads return the engine's last-known
|
||||
/// evaluation result; subscriptions forward engine-emitted change events as
|
||||
/// <see cref="ISubscribable.OnDataChange"/> events.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="IWritable"/> is deliberately not implemented — OPC UA client
|
||||
/// writes to virtual tags are rejected in <c>DriverNodeManager</c> before they
|
||||
/// reach here per Phase 7 decision #6. Scripts are the only write path, routed
|
||||
/// through <c>ctx.SetVirtualTag</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VirtualTagSource : IReadable, ISubscribable
|
||||
{
|
||||
private readonly VirtualTagEngine _engine;
|
||||
private readonly ConcurrentDictionary<string, Subscription> _subs = new(StringComparer.Ordinal);
|
||||
|
||||
public VirtualTagSource(VirtualTagEngine engine)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
}
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
|
||||
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
results[i] = _engine.Read(fullReferences[i]);
|
||||
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(results);
|
||||
}
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
TimeSpan publishingInterval,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (fullReferences is null) throw new ArgumentNullException(nameof(fullReferences));
|
||||
|
||||
var handle = new SubscriptionHandle(Guid.NewGuid().ToString("N"));
|
||||
var observers = new List<IDisposable>(fullReferences.Count);
|
||||
foreach (var path in fullReferences)
|
||||
{
|
||||
observers.Add(_engine.Subscribe(path, (p, snap) =>
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, p, snap))));
|
||||
}
|
||||
_subs[handle.DiagnosticId] = new Subscription(handle, observers);
|
||||
|
||||
// OPC UA convention: emit initial-data callback for each path with the current value.
|
||||
foreach (var path in fullReferences)
|
||||
{
|
||||
var snap = _engine.Read(path);
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, path, snap));
|
||||
}
|
||||
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is null) throw new ArgumentNullException(nameof(handle));
|
||||
if (_subs.TryRemove(handle.DiagnosticId, out var sub))
|
||||
{
|
||||
foreach (var d in sub.Observers)
|
||||
{
|
||||
try { d.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class SubscriptionHandle : ISubscriptionHandle
|
||||
{
|
||||
public SubscriptionHandle(string id) { DiagnosticId = id; }
|
||||
public string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
private sealed record Subscription(SubscriptionHandle Handle, IReadOnlyList<IDisposable> Observers);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -87,6 +87,16 @@ public static class EquipmentNodeWalker
|
||||
.GroupBy(t => t.EquipmentId!, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(t => t.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var virtualTagsByEquipment = (content.VirtualTags ?? [])
|
||||
.Where(v => v.Enabled)
|
||||
.GroupBy(v => v.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(v => v.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var scriptedAlarmsByEquipment = (content.ScriptedAlarms ?? [])
|
||||
.Where(a => a.Enabled)
|
||||
.GroupBy(a => a.EquipmentId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var area in content.Areas.OrderBy(a => a.Name, StringComparer.Ordinal))
|
||||
{
|
||||
var areaBuilder = namespaceBuilder.Folder(area.Name, area.Name);
|
||||
@@ -103,9 +113,17 @@ public static class EquipmentNodeWalker
|
||||
AddIdentifierProperties(equipmentBuilder, equipment);
|
||||
IdentificationFolderBuilder.Build(equipmentBuilder, equipment);
|
||||
|
||||
if (!tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags)) continue;
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
if (tagsByEquipment.TryGetValue(equipment.EquipmentId, out var equipmentTags))
|
||||
foreach (var tag in equipmentTags)
|
||||
AddTagVariable(equipmentBuilder, tag);
|
||||
|
||||
if (virtualTagsByEquipment.TryGetValue(equipment.EquipmentId, out var vTags))
|
||||
foreach (var vtag in vTags)
|
||||
AddVirtualTagVariable(equipmentBuilder, vtag);
|
||||
|
||||
if (scriptedAlarmsByEquipment.TryGetValue(equipment.EquipmentId, out var alarms))
|
||||
foreach (var alarm in alarms)
|
||||
AddScriptedAlarmVariable(equipmentBuilder, alarm);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,6 +175,55 @@ public static class EquipmentNodeWalker
|
||||
/// </summary>
|
||||
private static DriverDataType ParseDriverDataType(string raw) =>
|
||||
Enum.TryParse<DriverDataType>(raw, ignoreCase: true, out var parsed) ? parsed : DriverDataType.String;
|
||||
|
||||
/// <summary>
|
||||
/// Emit a <see cref="VirtualTag"/> row as a <see cref="NodeSourceKind.Virtual"/>
|
||||
/// variable node. <c>FullName</c> doubles as the UNS path Phase 7's VirtualTagEngine
|
||||
/// addresses its engine-side entries by. The <c>VirtualTagId</c> discriminator lets
|
||||
/// the DriverNodeManager dispatch Reads/Subscribes to the engine rather than any
|
||||
/// driver.
|
||||
/// </summary>
|
||||
private static void AddVirtualTagVariable(IAddressSpaceBuilder equipmentBuilder, VirtualTag vtag)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: vtag.VirtualTagId,
|
||||
DriverDataType: ParseDriverDataType(vtag.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: vtag.Historize,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false,
|
||||
Source: NodeSourceKind.Virtual,
|
||||
VirtualTagId: vtag.VirtualTagId,
|
||||
ScriptedAlarmId: null);
|
||||
equipmentBuilder.Variable(vtag.Name, vtag.Name, attr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit a <see cref="ScriptedAlarm"/> row as a <see cref="NodeSourceKind.ScriptedAlarm"/>
|
||||
/// variable node. The OPC UA Part 9 alarm-condition materialization happens at the
|
||||
/// node-manager level (which wires the concrete <c>AlarmConditionState</c> subclass
|
||||
/// per <see cref="ScriptedAlarm.AlarmType"/>); this walker provides the browse-level
|
||||
/// anchor + the <see cref="DriverAttributeInfo.IsAlarm"/> flag that triggers that
|
||||
/// materialization path.
|
||||
/// </summary>
|
||||
private static void AddScriptedAlarmVariable(IAddressSpaceBuilder equipmentBuilder, ScriptedAlarm alarm)
|
||||
{
|
||||
var attr = new DriverAttributeInfo(
|
||||
FullName: alarm.ScriptedAlarmId,
|
||||
DriverDataType: DriverDataType.Boolean,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: SecurityClassification.FreeAccess,
|
||||
IsHistorized: false,
|
||||
IsAlarm: true,
|
||||
WriteIdempotent: false,
|
||||
Source: NodeSourceKind.ScriptedAlarm,
|
||||
VirtualTagId: null,
|
||||
ScriptedAlarmId: alarm.ScriptedAlarmId);
|
||||
equipmentBuilder.Variable(alarm.Name, alarm.Name, attr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -170,4 +237,6 @@ public sealed record EquipmentNamespaceContent(
|
||||
IReadOnlyList<UnsArea> Areas,
|
||||
IReadOnlyList<UnsLine> Lines,
|
||||
IReadOnlyList<Equipment> Equipment,
|
||||
IReadOnlyList<Tag> Tags);
|
||||
IReadOnlyList<Tag> Tags,
|
||||
IReadOnlyList<VirtualTag>? VirtualTags = null,
|
||||
IReadOnlyList<ScriptedAlarm>? ScriptedAlarms = null);
|
||||
|
||||
@@ -60,6 +60,14 @@ public enum MessageKind : byte
|
||||
HostConnectivityStatus = 0x70,
|
||||
RuntimeStatusChange = 0x71,
|
||||
|
||||
// Phase 7 Stream D — historian alarm sink. Main server → Galaxy.Host batched
|
||||
// writes into the Aveva Historian alarm schema via the already-loaded
|
||||
// aahClientManaged DLLs. HistorianConnectivityStatus fires proactively from the
|
||||
// Host when the SDK session transitions so diagnostics flip promptly.
|
||||
HistorianAlarmEventRequest = 0x80,
|
||||
HistorianAlarmEventResponse = 0x81,
|
||||
HistorianConnectivityStatus = 0x82,
|
||||
|
||||
RecycleHostRequest = 0xF0,
|
||||
RecycleStatusResponse = 0xF1,
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 7 Stream D — IPC contracts for routing Part 9 alarm transitions from the
|
||||
/// main .NET 10 server into Galaxy.Host's already-loaded <c>aahClientManaged</c>
|
||||
/// DLLs. Reuses the Tier-C isolation + licensing pathway rather than loading 32-bit
|
||||
/// native historian code into the main server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Batched on the wire to amortize IPC overhead — the main server's SqliteStoreAndForwardSink
|
||||
/// ships up to 100 events per request per Phase 7 plan Stream D.5.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per-event outcomes (Ack / RetryPlease / PermanentFail) let the drain worker
|
||||
/// dead-letter malformed events without blocking neighbors in the batch.
|
||||
/// <see cref="HistorianConnectivityStatusNotification"/> fires proactively from
|
||||
/// the Host when the SDK session drops so the /hosts + /alarms/historian Admin
|
||||
/// diagnostics pages flip to red promptly instead of waiting for the next
|
||||
/// drain cycle.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAlarmEventRequest
|
||||
{
|
||||
[Key(0)] public HistorianAlarmEventDto[] Events { get; set; } = Array.Empty<HistorianAlarmEventDto>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAlarmEventResponse
|
||||
{
|
||||
/// <summary>Per-event outcome, same order as the request.</summary>
|
||||
[Key(0)] public HistorianAlarmEventOutcomeDto[] Outcomes { get; set; } = Array.Empty<HistorianAlarmEventOutcomeDto>();
|
||||
}
|
||||
|
||||
/// <summary>Outcome enum — bytes on the wire so it stays compact.</summary>
|
||||
public enum HistorianAlarmEventOutcomeDto : byte
|
||||
{
|
||||
/// <summary>Successfully persisted to the historian — remove from queue.</summary>
|
||||
Ack = 0,
|
||||
/// <summary>Transient failure (historian disconnected, timeout, busy) — retry after backoff.</summary>
|
||||
RetryPlease = 1,
|
||||
/// <summary>Permanent failure (malformed, unrecoverable SDK error) — move to dead-letter.</summary>
|
||||
PermanentFail = 2,
|
||||
}
|
||||
|
||||
/// <summary>One alarm-transition payload. Fields mirror <c>Core.AlarmHistorian.AlarmHistorianEvent</c>.</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianAlarmEventDto
|
||||
{
|
||||
[Key(0)] public string AlarmId { get; set; } = string.Empty;
|
||||
[Key(1)] public string EquipmentPath { get; set; } = string.Empty;
|
||||
[Key(2)] public string AlarmName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Concrete Part 9 subtype name — "LimitAlarm" / "OffNormalAlarm" / "AlarmCondition" / "DiscreteAlarm".</summary>
|
||||
[Key(3)] public string AlarmTypeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Numeric severity the Host maps to the historian's priority scale.</summary>
|
||||
[Key(4)] public int Severity { get; set; }
|
||||
|
||||
/// <summary>Which transition this event represents — "Activated" / "Cleared" / "Acknowledged" / etc.</summary>
|
||||
[Key(5)] public string EventKind { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Pre-rendered message — template tokens resolved upstream.</summary>
|
||||
[Key(6)] public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Operator who triggered the transition. "system" for engine-driven events.</summary>
|
||||
[Key(7)] public string User { get; set; } = "system";
|
||||
|
||||
/// <summary>Operator-supplied free-form comment, if any.</summary>
|
||||
[Key(8)] public string? Comment { get; set; }
|
||||
|
||||
/// <summary>Source timestamp (UTC Unix milliseconds).</summary>
|
||||
[Key(9)] public long TimestampUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proactive notification — Galaxy.Host pushes this when the historian SDK session
|
||||
/// transitions (connected / disconnected / degraded). The main server reflects this
|
||||
/// into the historian sink status so Admin UI surfaces the problem without the
|
||||
/// operator having to scrutinize drain cadence.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HistorianConnectivityStatusNotification
|
||||
{
|
||||
[Key(0)] public string Status { get; set; } = "unknown"; // connected | disconnected | degraded
|
||||
[Key(1)] public string? Detail { get; set; }
|
||||
[Key(2)] public long ObservedAtUtcUnixMs { get; set; }
|
||||
}
|
||||
196
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs
Normal file
196
tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/Phase7ServicesTests.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-side services shipped in Phase 7 Stream F — draft CRUD for scripts + virtual
|
||||
/// tags + scripted alarms, the pre-publish test harness, and the historian
|
||||
/// diagnostics façade.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ServicesTests
|
||||
{
|
||||
private static OtOpcUaConfigDbContext NewDb([System.Runtime.CompilerServices.CallerMemberName] string test = "")
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseInMemoryDatabase($"phase7-{test}-{Guid.NewGuid():N}")
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_AddAsync_generates_logical_id_and_hash()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
|
||||
var s = await svc.AddAsync(5, "line-rate", "return ctx.GetTag(\"a\").Value;", default);
|
||||
|
||||
s.ScriptId.ShouldStartWith("scr-");
|
||||
s.GenerationId.ShouldBe(5);
|
||||
s.SourceHash.Length.ShouldBe(64);
|
||||
(await svc.ListAsync(5, default)).Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_UpdateAsync_recomputes_hash_on_source_change()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||
var hashBefore = s.SourceHash;
|
||||
|
||||
var updated = await svc.UpdateAsync(5, s.ScriptId, "s", "return 2;", default);
|
||||
updated.SourceHash.ShouldNotBe(hashBefore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_UpdateAsync_same_source_same_hash()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
var s = await svc.AddAsync(5, "s", "return 1;", default);
|
||||
var updated = await svc.UpdateAsync(5, s.ScriptId, "renamed", "return 1;", default);
|
||||
|
||||
updated.SourceHash.ShouldBe(s.SourceHash, "source unchanged → hash unchanged → compile cache hit preserved");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptService_DeleteAsync_is_idempotent()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptService(db);
|
||||
|
||||
await Should.NotThrowAsync(() => svc.DeleteAsync(5, "nonexistent", default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VirtualTagService_round_trips_trigger_flags()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new VirtualTagService(db);
|
||||
|
||||
var v = await svc.AddAsync(7, "eq-1", "LineRate", "Float32", "scr-1",
|
||||
changeTriggered: true, timerIntervalMs: 1000, historize: true, default);
|
||||
|
||||
v.ChangeTriggered.ShouldBeTrue();
|
||||
v.TimerIntervalMs.ShouldBe(1000);
|
||||
v.Historize.ShouldBeTrue();
|
||||
v.Enabled.ShouldBeTrue();
|
||||
(await svc.ListAsync(7, default)).Single().VirtualTagId.ShouldBe(v.VirtualTagId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VirtualTagService_update_enabled_toggles_flag()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new VirtualTagService(db);
|
||||
var v = await svc.AddAsync(7, "eq-1", "N", "Int32", "scr-1", true, null, false, default);
|
||||
|
||||
var disabled = await svc.UpdateEnabledAsync(7, v.VirtualTagId, false, default);
|
||||
disabled.Enabled.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptedAlarmService_defaults_HistorizeToAveva_true_per_plan_decision_15()
|
||||
{
|
||||
using var db = NewDb();
|
||||
var svc = new ScriptedAlarmService(db);
|
||||
|
||||
var a = await svc.AddAsync(9, "eq-1", "HighTemp", "LimitAlarm", severity: 800,
|
||||
messageTemplate: "{Temp} too high", predicateScriptId: "scr-9",
|
||||
historizeToAveva: true, retain: true, default);
|
||||
|
||||
a.HistorizeToAveva.ShouldBeTrue();
|
||||
a.Severity.ShouldBe(800);
|
||||
a.ScriptedAlarmId.ShouldStartWith("sal-");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_runs_successful_script_and_captures_writes()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """
|
||||
ctx.SetVirtualTag("Out", 42);
|
||||
return ctx.GetTag("In").Value;
|
||||
""";
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["In"] = new(123, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.Success);
|
||||
result.Output.ShouldBe(123);
|
||||
result.Writes["Out"].ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_missing_synthetic_input()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """return ctx.GetTag("A").Value;""";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.MissingInputs);
|
||||
result.Errors[0].ShouldContain("A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_extra_synthetic_input_not_referenced_by_script()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """return 1;"""; // no GetTag calls
|
||||
var inputs = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["Unexpected"] = new(0, 0u, DateTime.UtcNow, DateTime.UtcNow),
|
||||
};
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, inputs, default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.UnknownInputs);
|
||||
result.Errors[0].ShouldContain("Unexpected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_rejects_non_literal_path()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = """
|
||||
var p = "A";
|
||||
return ctx.GetTag(p).Value;
|
||||
""";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.DependencyRejected);
|
||||
result.Errors.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTestHarness_surfaces_compile_error_as_Threw()
|
||||
{
|
||||
var harness = new ScriptTestHarnessService();
|
||||
var source = "this is not valid C#;";
|
||||
|
||||
var result = await harness.RunVirtualTagAsync(source, new Dictionary<string, DataValueSnapshot>(), default);
|
||||
|
||||
result.Outcome.ShouldBe(ScriptTestOutcome.Threw);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HistorianDiagnosticsService_reports_Disabled_for_null_sink()
|
||||
{
|
||||
var diag = new HistorianDiagnosticsService(NullAlarmHistorianSink.Instance);
|
||||
diag.GetStatus().DrainState.ShouldBe(HistorianDrainState.Disabled);
|
||||
diag.TryRetryDeadLettered().ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the Phase 7 Stream E entities (<see cref="Script"/>, <see cref="VirtualTag"/>,
|
||||
/// <see cref="ScriptedAlarm"/>, <see cref="ScriptedAlarmState"/>) register correctly in
|
||||
/// the EF model, map to the expected tables/columns/indexes, and carry the check constraints
|
||||
/// the plan decisions call for. Introspection only — no SQL Server required.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Phase7ScriptingEntitiesTests
|
||||
{
|
||||
private static OtOpcUaConfigDbContext BuildCtx()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer("Server=(local);Database=dummy;Integrated Security=true") // not connected
|
||||
.Options;
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
|
||||
private static Microsoft.EntityFrameworkCore.Metadata.IModel DesignModel(OtOpcUaConfigDbContext ctx)
|
||||
=> ctx.GetService<IDesignTimeModel>().Model;
|
||||
|
||||
[Fact]
|
||||
public void Script_entity_registered_with_expected_table_and_columns()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
||||
|
||||
entity.GetTableName().ShouldBe("Script");
|
||||
entity.FindProperty(nameof(Script.ScriptRowId)).ShouldNotBeNull();
|
||||
entity.FindProperty(nameof(Script.ScriptId)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(64);
|
||||
entity.FindProperty(nameof(Script.SourceCode)).ShouldNotBeNull()
|
||||
.GetColumnType().ShouldBe("nvarchar(max)");
|
||||
entity.FindProperty(nameof(Script.SourceHash)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(64);
|
||||
entity.FindProperty(nameof(Script.Language)).ShouldNotBeNull()
|
||||
.GetMaxLength().ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Script_has_unique_logical_id_per_generation()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(Script)).ShouldNotBeNull();
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.IsUnique && i.GetDatabaseName() == "UX_Script_Generation_LogicalId");
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.GetDatabaseName() == "IX_Script_Generation_SourceHash");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_entity_registered_with_trigger_check_constraint()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("VirtualTag");
|
||||
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_VirtualTag_Trigger_AtLeastOne");
|
||||
checks.ShouldContain("CK_VirtualTag_TimerInterval_Min");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_enforces_unique_name_per_Equipment()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.GetIndexes().ShouldContain(
|
||||
i => i.IsUnique && i.GetDatabaseName() == "UX_VirtualTag_Generation_EquipmentPath");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualTag_has_ChangeTriggered_and_Historize_flags()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(VirtualTag)).ShouldNotBeNull();
|
||||
entity.FindProperty(nameof(VirtualTag.ChangeTriggered)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(bool));
|
||||
entity.FindProperty(nameof(VirtualTag.Historize)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(bool));
|
||||
entity.FindProperty(nameof(VirtualTag.TimerIntervalMs)).ShouldNotBeNull()
|
||||
.ClrType.ShouldBe(typeof(int?));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_entity_registered_with_severity_and_type_checks()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarm)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("ScriptedAlarm");
|
||||
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_ScriptedAlarm_Severity_Range");
|
||||
checks.ShouldContain("CK_ScriptedAlarm_AlarmType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarm_has_HistorizeToAveva_default_true_per_plan_decision_15()
|
||||
{
|
||||
// Defaults live on the CLR default assignment — verify the initializer.
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmId = "a1",
|
||||
EquipmentId = "eq1",
|
||||
Name = "n",
|
||||
AlarmType = "LimitAlarm",
|
||||
MessageTemplate = "m",
|
||||
PredicateScriptId = "s1",
|
||||
};
|
||||
alarm.HistorizeToAveva.ShouldBeTrue();
|
||||
alarm.Retain.ShouldBeTrue();
|
||||
alarm.Severity.ShouldBe(500);
|
||||
alarm.Enabled.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_keyed_on_ScriptedAlarmId_not_generation_scoped()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = ctx.Model.FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
entity.GetTableName().ShouldBe("ScriptedAlarmState");
|
||||
|
||||
var pk = entity.FindPrimaryKey().ShouldNotBeNull();
|
||||
pk.Properties.Count.ShouldBe(1);
|
||||
pk.Properties[0].Name.ShouldBe(nameof(ScriptedAlarmState.ScriptedAlarmId));
|
||||
|
||||
// State is NOT generation-scoped — GenerationId column should not exist per plan decision #14.
|
||||
entity.FindProperty("GenerationId").ShouldBeNull(
|
||||
"ack state follows alarm identity across generations");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_default_state_values_match_Part9_initial_states()
|
||||
{
|
||||
var state = new ScriptedAlarmState
|
||||
{
|
||||
ScriptedAlarmId = "a1",
|
||||
EnabledState = "Enabled",
|
||||
AckedState = "Unacknowledged",
|
||||
ConfirmedState = "Unconfirmed",
|
||||
ShelvingState = "Unshelved",
|
||||
};
|
||||
state.CommentsJson.ShouldBe("[]");
|
||||
state.LastAckUser.ShouldBeNull();
|
||||
state.LastAckUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptedAlarmState_has_JSON_check_constraint_on_CommentsJson()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
var entity = DesignModel(ctx).FindEntityType(typeof(ScriptedAlarmState)).ShouldNotBeNull();
|
||||
var checks = entity.GetCheckConstraints().Select(c => c.Name).ToArray();
|
||||
checks.ShouldContain("CK_ScriptedAlarmState_CommentsJson_IsJson");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void All_new_entities_exposed_via_DbSet()
|
||||
{
|
||||
using var ctx = BuildCtx();
|
||||
ctx.Scripts.ShouldNotBeNull();
|
||||
ctx.VirtualTags.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarms.ShouldNotBeNull();
|
||||
ctx.ScriptedAlarmStates.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
|
||||
{
|
||||
// The migration type carries the design-time snapshot + Up/Down methods EF uses to
|
||||
// apply the schema. Missing = schema won't roll forward in deployments.
|
||||
var t = typeof(Migrations.AddPhase7ScriptingTables);
|
||||
t.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the durable SQLite store-and-forward queue behind the historian sink:
|
||||
/// round-trip Ack, backoff ladder on RetryPlease, dead-lettering on PermanentFail,
|
||||
/// capacity eviction, and retention-based dead-letter purge.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class SqliteStoreAndForwardSinkTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
private readonly ILogger _log;
|
||||
|
||||
public SqliteStoreAndForwardSinkTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-historian-{Guid.NewGuid():N}.sqlite");
|
||||
_log = new LoggerConfiguration().MinimumLevel.Verbose().CreateLogger();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { if (File.Exists(_dbPath)) File.Delete(_dbPath); } catch { }
|
||||
}
|
||||
|
||||
private sealed class FakeWriter : IAlarmHistorianWriter
|
||||
{
|
||||
public Queue<HistorianWriteOutcome> NextOutcomePerEvent { get; } = new();
|
||||
public HistorianWriteOutcome DefaultOutcome { get; set; } = HistorianWriteOutcome.Ack;
|
||||
public List<IReadOnlyList<AlarmHistorianEvent>> Batches { get; } = [];
|
||||
public Exception? ThrowOnce { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<HistorianWriteOutcome>> WriteBatchAsync(
|
||||
IReadOnlyList<AlarmHistorianEvent> batch, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnce is not null)
|
||||
{
|
||||
var e = ThrowOnce;
|
||||
ThrowOnce = null;
|
||||
throw e;
|
||||
}
|
||||
Batches.Add(batch);
|
||||
var outcomes = new List<HistorianWriteOutcome>();
|
||||
for (var i = 0; i < batch.Count; i++)
|
||||
outcomes.Add(NextOutcomePerEvent.Count > 0 ? NextOutcomePerEvent.Dequeue() : DefaultOutcome);
|
||||
return Task.FromResult<IReadOnlyList<HistorianWriteOutcome>>(outcomes);
|
||||
}
|
||||
}
|
||||
|
||||
private static AlarmHistorianEvent Event(string alarmId, DateTime? ts = null) => new(
|
||||
AlarmId: alarmId,
|
||||
EquipmentPath: "/Site/Line1/Cell",
|
||||
AlarmName: "HighTemp",
|
||||
AlarmTypeName: "LimitAlarm",
|
||||
Severity: AlarmSeverity.High,
|
||||
EventKind: "Activated",
|
||||
Message: "temp exceeded",
|
||||
User: "system",
|
||||
Comment: null,
|
||||
TimestampUtc: ts ?? DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task EnqueueThenDrain_Ack_removes_row()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
sink.GetStatus().QueueDepth.ShouldBe(1);
|
||||
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
writer.Batches.Count.ShouldBe(1);
|
||||
writer.Batches[0].Count.ShouldBe(1);
|
||||
writer.Batches[0][0].AlarmId.ShouldBe("A1");
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(0);
|
||||
status.DeadLetterDepth.ShouldBe(0);
|
||||
status.LastSuccessUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drain_with_empty_queue_is_noop()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
writer.Batches.ShouldBeEmpty();
|
||||
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.Idle);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryPlease_bumps_backoff_and_keeps_row()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
var before = sink.CurrentBackoff;
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.CurrentBackoff.ShouldBeGreaterThan(before);
|
||||
sink.GetStatus().QueueDepth.ShouldBe(1, "row stays in queue for retry");
|
||||
sink.GetStatus().DrainState.ShouldBe(HistorianDrainState.BackingOff);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ack_after_Retry_resets_backoff()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.RetryPlease);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.CurrentBackoff.ShouldBeGreaterThan(TimeSpan.FromSeconds(1) - TimeSpan.FromMilliseconds(1));
|
||||
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
sink.GetStatus().QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PermanentFail_dead_letters_one_row_only()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.Ack);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
|
||||
await sink.EnqueueAsync(Event("good"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(0, "good row acked");
|
||||
status.DeadLetterDepth.ShouldBe(1, "bad row dead-lettered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writer_exception_treated_as_retry_for_whole_batch()
|
||||
{
|
||||
var writer = new FakeWriter { ThrowOnce = new InvalidOperationException("pipe broken") };
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(1);
|
||||
status.LastError.ShouldBe("pipe broken");
|
||||
status.DrainState.ShouldBe(HistorianDrainState.BackingOff);
|
||||
|
||||
// Next drain after the writer recovers should Ack.
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.GetStatus().QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Capacity_eviction_drops_oldest_nondeadlettered_row()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
using var sink = new SqliteStoreAndForwardSink(
|
||||
_dbPath, writer, _log, batchSize: 100, capacity: 3);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
await sink.EnqueueAsync(Event("A2"), CancellationToken.None);
|
||||
await sink.EnqueueAsync(Event("A3"), CancellationToken.None);
|
||||
// A4 enqueue must evict the oldest (A1).
|
||||
await sink.EnqueueAsync(Event("A4"), CancellationToken.None);
|
||||
|
||||
sink.GetStatus().QueueDepth.ShouldBe(3);
|
||||
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
var drained = writer.Batches[0].Select(e => e.AlarmId).ToArray();
|
||||
drained.ShouldNotContain("A1");
|
||||
drained.ShouldContain("A2");
|
||||
drained.ShouldContain("A3");
|
||||
drained.ShouldContain("A4");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deadlettered_rows_are_purged_past_retention()
|
||||
{
|
||||
var now = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime clock = now;
|
||||
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
|
||||
using var sink = new SqliteStoreAndForwardSink(
|
||||
_dbPath, writer, _log, deadLetterRetention: TimeSpan.FromDays(30),
|
||||
clock: () => clock);
|
||||
|
||||
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.GetStatus().DeadLetterDepth.ShouldBe(1);
|
||||
|
||||
// Advance past retention + tick drain (which runs PurgeAgedDeadLetters).
|
||||
clock = now.AddDays(31);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.GetStatus().DeadLetterDepth.ShouldBe(0, "purged past retention");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RetryDeadLettered_requeues_for_retry()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
writer.NextOutcomePerEvent.Enqueue(HistorianWriteOutcome.PermanentFail);
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("bad"), CancellationToken.None);
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
sink.GetStatus().DeadLetterDepth.ShouldBe(1);
|
||||
|
||||
var revived = sink.RetryDeadLettered();
|
||||
revived.ShouldBe(1);
|
||||
|
||||
var status = sink.GetStatus();
|
||||
status.QueueDepth.ShouldBe(1);
|
||||
status.DeadLetterDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backoff_ladder_caps_at_60s()
|
||||
{
|
||||
var writer = new FakeWriter { DefaultOutcome = HistorianWriteOutcome.RetryPlease };
|
||||
using var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
|
||||
await sink.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
|
||||
// 10 retry rounds — ladder should cap at 60s.
|
||||
for (var i = 0; i < 10; i++)
|
||||
await sink.DrainOnceAsync(CancellationToken.None);
|
||||
|
||||
sink.CurrentBackoff.ShouldBe(TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NullAlarmHistorianSink_reports_disabled_status()
|
||||
{
|
||||
var s = NullAlarmHistorianSink.Instance.GetStatus();
|
||||
s.DrainState.ShouldBe(HistorianDrainState.Disabled);
|
||||
s.QueueDepth.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NullAlarmHistorianSink_swallows_enqueue()
|
||||
{
|
||||
// Should not throw or persist anything.
|
||||
await NullAlarmHistorianSink.Instance.EnqueueAsync(Event("A1"), CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_rejects_bad_args()
|
||||
{
|
||||
var w = new FakeWriter();
|
||||
Should.Throw<ArgumentException>(() => new SqliteStoreAndForwardSink("", w, _log));
|
||||
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, null!, _log));
|
||||
Should.Throw<ArgumentNullException>(() => new SqliteStoreAndForwardSink(_dbPath, w, null!));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, batchSize: 0));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => new SqliteStoreAndForwardSink(_dbPath, w, _log, capacity: 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disposed_sink_rejects_enqueue()
|
||||
{
|
||||
var writer = new FakeWriter();
|
||||
var sink = new SqliteStoreAndForwardSink(_dbPath, writer, _log);
|
||||
sink.Dispose();
|
||||
|
||||
await Should.ThrowAsync<ObjectDisposedException>(
|
||||
() => sink.EnqueueAsync(Event("A1"), CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian\ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
public sealed class FakeUpstream : ITagUpstreamSource
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs
|
||||
= new(StringComparer.Ordinal);
|
||||
public int ActiveSubscriptionCount { get; private set; }
|
||||
|
||||
public void Set(string path, object? value, uint statusCode = 0u)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||
}
|
||||
|
||||
public void Push(string path, object? value, uint statusCode = 0u)
|
||||
{
|
||||
Set(path, value, statusCode);
|
||||
if (_subs.TryGetValue(path, out var list))
|
||||
{
|
||||
Action<string, DataValueSnapshot>[] snap;
|
||||
lock (list) { snap = list.ToArray(); }
|
||||
foreach (var obs in snap) obs(path, _values[path]);
|
||||
}
|
||||
}
|
||||
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
=> _values.TryGetValue(path, out var v) ? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
var list = _subs.GetOrAdd(path, _ => []);
|
||||
lock (list) { list.Add(observer); }
|
||||
ActiveSubscriptionCount++;
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
private sealed class Unsub : IDisposable
|
||||
{
|
||||
private readonly FakeUpstream _up;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||
{ _up = up; _path = path; _observer = observer; }
|
||||
public void Dispose()
|
||||
{
|
||||
if (_up._subs.TryGetValue(_path, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
if (list.Remove(_observer)) _up.ActiveSubscriptionCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MessageTemplateTests
|
||||
{
|
||||
private static DataValueSnapshot Good(object? v) =>
|
||||
new(v, 0u, DateTime.UtcNow, DateTime.UtcNow);
|
||||
private static DataValueSnapshot Bad() =>
|
||||
new(null, 0x80050000u, null, DateTime.UtcNow);
|
||||
|
||||
private static DataValueSnapshot? Resolver(Dictionary<string, DataValueSnapshot> map, string path)
|
||||
=> map.TryGetValue(path, out var v) ? v : null;
|
||||
|
||||
[Fact]
|
||||
public void No_tokens_returns_template_unchanged()
|
||||
{
|
||||
MessageTemplate.Resolve("No tokens here", _ => null).ShouldBe("No tokens here");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_token_substituted()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["Tank/Temp"] = Good(75.5) };
|
||||
MessageTemplate.Resolve("Temp={Tank/Temp}C", p => Resolver(map, p)).ShouldBe("Temp=75.5C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_tokens_substituted()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["A"] = Good(10),
|
||||
["B"] = Good("on"),
|
||||
};
|
||||
MessageTemplate.Resolve("{A}/{B}", p => Resolver(map, p)).ShouldBe("10/on");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bad_quality_token_becomes_question_mark()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["Bad"] = Bad() };
|
||||
MessageTemplate.Resolve("value={Bad}", p => Resolver(map, p)).ShouldBe("value={?}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_path_becomes_question_mark()
|
||||
{
|
||||
MessageTemplate.Resolve("value={DoesNotExist}", _ => null).ShouldBe("value={?}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_value_with_good_quality_becomes_question_mark()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["X"] = Good(null) };
|
||||
MessageTemplate.Resolve("{X}", p => Resolver(map, p)).ShouldBe("{?}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tokens_with_slashes_and_dots_resolved()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot>
|
||||
{
|
||||
["Line1/Pump.Speed"] = Good(1200),
|
||||
};
|
||||
MessageTemplate.Resolve("rpm={Line1/Pump.Speed}", p => Resolver(map, p))
|
||||
.ShouldBe("rpm=1200");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_template_returns_empty()
|
||||
{
|
||||
MessageTemplate.Resolve("", _ => null).ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_template_returns_empty_without_throwing()
|
||||
{
|
||||
MessageTemplate.Resolve(null!, _ => null).ShouldBe("");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTokenPaths_returns_every_distinct_token()
|
||||
{
|
||||
var tokens = MessageTemplate.ExtractTokenPaths("{A}/{B}/{A}/{C}");
|
||||
tokens.ShouldBe(new[] { "A", "B", "A", "C" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractTokenPaths_empty_for_tokenless_template()
|
||||
{
|
||||
MessageTemplate.ExtractTokenPaths("No tokens").ShouldBeEmpty();
|
||||
MessageTemplate.ExtractTokenPaths("").ShouldBeEmpty();
|
||||
MessageTemplate.ExtractTokenPaths(null).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Whitespace_inside_token_is_trimmed()
|
||||
{
|
||||
var map = new Dictionary<string, DataValueSnapshot> { ["A"] = Good(42) };
|
||||
MessageTemplate.Resolve("{ A }", p => Resolver(map, p)).ShouldBe("42");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure state-machine tests — no engine, no I/O, no async. Every transition rule
|
||||
/// from Phase 7 plan Stream C.2 / C.3 has at least one locking test so regressions
|
||||
/// surface as clear failures rather than subtle alarm-behavior drift.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class Part9StateMachineTests
|
||||
{
|
||||
private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
private static AlarmConditionState Fresh() => AlarmConditionState.Fresh("alarm-1", T0);
|
||||
|
||||
[Fact]
|
||||
public void Predicate_true_on_inactive_becomes_active_and_emits_Activated()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyPredicate(Fresh(), predicateTrue: true, T0.AddSeconds(1));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Active);
|
||||
r.State.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Unconfirmed);
|
||||
r.Emission.ShouldBe(EmissionKind.Activated);
|
||||
r.State.LastActiveUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Predicate_false_on_active_becomes_inactive_and_emits_Cleared()
|
||||
{
|
||||
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(active, false, T0.AddSeconds(2));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
r.Emission.ShouldBe(EmissionKind.Cleared);
|
||||
r.State.LastClearedUtc.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Predicate_unchanged_state_emits_None()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyPredicate(Fresh(), false, T0);
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disabled_alarm_ignores_predicate()
|
||||
{
|
||||
var disabled = Part9StateMachine.ApplyDisable(Fresh(), "op1", T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(disabled, true, T0.AddSeconds(2));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_from_unacked_records_user_and_emits()
|
||||
{
|
||||
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyAcknowledge(active, "alice", "looking into it", T0.AddSeconds(2));
|
||||
r.State.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
r.State.LastAckUser.ShouldBe("alice");
|
||||
r.State.LastAckComment.ShouldBe("looking into it");
|
||||
r.State.Comments.Count.ShouldBe(1);
|
||||
r.Emission.ShouldBe(EmissionKind.Acknowledged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_when_already_acked_is_noop()
|
||||
{
|
||||
var active = Part9StateMachine.ApplyPredicate(Fresh(), true, T0.AddSeconds(1)).State;
|
||||
var acked = Part9StateMachine.ApplyAcknowledge(active, "alice", null, T0.AddSeconds(2)).State;
|
||||
var r = Part9StateMachine.ApplyAcknowledge(acked, "alice", null, T0.AddSeconds(3));
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_without_user_throws()
|
||||
{
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
Part9StateMachine.ApplyAcknowledge(Fresh(), "", null, T0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Confirm_after_clear_records_user_and_emits()
|
||||
{
|
||||
// Walk: activate -> ack -> clear -> confirm
|
||||
var s = Fresh();
|
||||
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)).State;
|
||||
s = Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)).State;
|
||||
s = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3)).State;
|
||||
|
||||
var r = Part9StateMachine.ApplyConfirm(s, "bob", "resolved", T0.AddSeconds(4));
|
||||
r.State.Confirmed.ShouldBe(AlarmConfirmedState.Confirmed);
|
||||
r.State.LastConfirmUser.ShouldBe("bob");
|
||||
r.Emission.ShouldBe(EmissionKind.Confirmed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneShotShelve_suppresses_next_activation_emission()
|
||||
{
|
||||
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0.AddSeconds(1)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2));
|
||||
r.State.Active.ShouldBe(AlarmActiveState.Active, "state still advances");
|
||||
r.Emission.ShouldBe(EmissionKind.Suppressed, "but subscribers don't see it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneShotShelve_expires_on_clear()
|
||||
{
|
||||
var s = Fresh();
|
||||
s = Part9StateMachine.ApplyOneShotShelve(s, "alice", T0.AddSeconds(1)).State;
|
||||
s = Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(2)).State;
|
||||
var r = Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(3));
|
||||
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved, "OneShot expires on clear");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimedShelve_requires_future_unshelve_time()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", T0, T0.AddSeconds(5)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimedShelve_expires_via_shelving_check()
|
||||
{
|
||||
var until = T0.AddMinutes(5);
|
||||
var shelved = Part9StateMachine.ApplyTimedShelve(Fresh(), "alice", until, T0).State;
|
||||
shelved.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||
|
||||
// Before expiry — still shelved.
|
||||
var earlier = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(3));
|
||||
earlier.State.Shelving.Kind.ShouldBe(ShelvingKind.Timed);
|
||||
earlier.Emission.ShouldBe(EmissionKind.None);
|
||||
|
||||
// After expiry — auto-unshelved + emission.
|
||||
var after = Part9StateMachine.ApplyShelvingCheck(shelved, T0.AddMinutes(6));
|
||||
after.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
after.Emission.ShouldBe(EmissionKind.Unshelved);
|
||||
after.State.Comments.Any(c => c.Kind == "AutoUnshelve").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unshelve_from_unshelved_is_noop()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyUnshelve(Fresh(), "alice", T0);
|
||||
r.Emission.ShouldBe(EmissionKind.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_Unshelve_emits_event()
|
||||
{
|
||||
var s = Part9StateMachine.ApplyOneShotShelve(Fresh(), "alice", T0).State;
|
||||
var r = Part9StateMachine.ApplyUnshelve(s, "bob", T0.AddSeconds(30));
|
||||
r.State.Shelving.Kind.ShouldBe(ShelvingKind.Unshelved);
|
||||
r.Emission.ShouldBe(EmissionKind.Unshelved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddComment_appends_to_audit_trail_with_event()
|
||||
{
|
||||
var r = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "investigating", T0.AddSeconds(5));
|
||||
r.State.Comments.Count.ShouldBe(1);
|
||||
r.State.Comments[0].Kind.ShouldBe("AddComment");
|
||||
r.State.Comments[0].User.ShouldBe("alice");
|
||||
r.State.Comments[0].Text.ShouldBe("investigating");
|
||||
r.Emission.ShouldBe(EmissionKind.CommentAdded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Comments_are_append_only_never_rewritten()
|
||||
{
|
||||
var s = Part9StateMachine.ApplyAddComment(Fresh(), "alice", "first", T0.AddSeconds(1)).State;
|
||||
s = Part9StateMachine.ApplyAddComment(s, "bob", "second", T0.AddSeconds(2)).State;
|
||||
s = Part9StateMachine.ApplyAddComment(s, "carol", "third", T0.AddSeconds(3)).State;
|
||||
s.Comments.Count.ShouldBe(3);
|
||||
s.Comments[0].User.ShouldBe("alice");
|
||||
s.Comments[1].User.ShouldBe("bob");
|
||||
s.Comments[2].User.ShouldBe("carol");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Full_lifecycle_walk_produces_every_expected_emission()
|
||||
{
|
||||
// Walk a condition through its whole lifecycle and make sure emissions line up.
|
||||
var emissions = new List<EmissionKind>();
|
||||
var s = Fresh();
|
||||
|
||||
s = Capture(Part9StateMachine.ApplyPredicate(s, true, T0.AddSeconds(1)));
|
||||
s = Capture(Part9StateMachine.ApplyAcknowledge(s, "alice", null, T0.AddSeconds(2)));
|
||||
s = Capture(Part9StateMachine.ApplyAddComment(s, "alice", "need to investigate", T0.AddSeconds(3)));
|
||||
s = Capture(Part9StateMachine.ApplyPredicate(s, false, T0.AddSeconds(4)));
|
||||
s = Capture(Part9StateMachine.ApplyConfirm(s, "bob", null, T0.AddSeconds(5)));
|
||||
|
||||
emissions.ShouldBe(new[] {
|
||||
EmissionKind.Activated,
|
||||
EmissionKind.Acknowledged,
|
||||
EmissionKind.CommentAdded,
|
||||
EmissionKind.Cleared,
|
||||
EmissionKind.Confirmed,
|
||||
});
|
||||
|
||||
AlarmConditionState Capture(TransitionResult r) { emissions.Add(r.Emission); return r.State; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end engine tests: load, predicate evaluation, change-triggered
|
||||
/// re-evaluation, state persistence, startup recovery, error isolation.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmEngineTests
|
||||
{
|
||||
private static ScriptedAlarmEngine Build(FakeUpstream up, out IAlarmStateStore store)
|
||||
{
|
||||
store = new InMemoryAlarmStateStore();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
return new ScriptedAlarmEngine(up, store, new ScriptLoggerFactory(logger), logger);
|
||||
}
|
||||
|
||||
private static ScriptedAlarmDefinition Alarm(string id, string predicate,
|
||||
string msg = "condition", AlarmSeverity sev = AlarmSeverity.High) =>
|
||||
new(AlarmId: id,
|
||||
EquipmentPath: "Plant/Line1/Reactor",
|
||||
AlarmName: id,
|
||||
Kind: AlarmKind.AlarmCondition,
|
||||
Severity: sev,
|
||||
MessageTemplate: msg,
|
||||
PredicateScriptSource: predicate);
|
||||
|
||||
[Fact]
|
||||
public async Task Load_compiles_and_subscribes_to_referenced_upstreams()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
|
||||
await eng.LoadAsync([Alarm("a1", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
eng.LoadedAlarmIds.ShouldContain("a1");
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Compile_failures_aggregated_into_one_error()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var eng = Build(up, out _);
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await eng.LoadAsync([
|
||||
Alarm("bad1", "return unknownIdentifier;"),
|
||||
Alarm("good", "return true;"),
|
||||
Alarm("bad2", "var x = alsoUnknown; return x;"),
|
||||
], TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("2 alarm(s) did not compile");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upstream_change_re_evaluates_predicate_and_emits_Activated()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await WaitForAsync(() => events.Count > 0);
|
||||
|
||||
events[0].AlarmId.ShouldBe("HighTemp");
|
||||
events[0].Emission.ShouldBe(EmissionKind.Activated);
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clearing_upstream_emits_Cleared_event()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
// Startup sees 150 → active.
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 50);
|
||||
await WaitForAsync(() => events.Any(e => e.Emission == EmissionKind.Cleared));
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_template_resolves_tag_values_at_emission()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
up.Set("Limit", 100);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([
|
||||
new ScriptedAlarmDefinition(
|
||||
"HighTemp", "Plant/Line1", "HighTemp",
|
||||
AlarmKind.LimitAlarm, AlarmSeverity.High,
|
||||
"Temp {Temp}C exceeded limit {Limit}C",
|
||||
"""return (int)ctx.GetTag("Temp").Value > (int)ctx.GetTag("Limit").Value;"""),
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await WaitForAsync(() => events.Any());
|
||||
|
||||
events[0].Message.ShouldBe("Temp 150C exceeded limit 100C");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ack_records_user_and_persists_to_store()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
using var eng = Build(up, out var store);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.AcknowledgeAsync("HighTemp", "alice", "checking", TestContext.Current.CancellationToken);
|
||||
|
||||
var persisted = await store.LoadAsync("HighTemp", TestContext.Current.CancellationToken);
|
||||
persisted.ShouldNotBeNull();
|
||||
persisted!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
persisted.LastAckUser.ShouldBe("alice");
|
||||
persisted.LastAckComment.ShouldBe("checking");
|
||||
persisted.Comments.Any(c => c.Kind == "Acknowledge" && c.User == "alice").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Startup_recovery_preserves_ack_but_rederives_active_from_predicate()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50); // predicate will go false on second load
|
||||
|
||||
// First run — alarm goes active + operator acks.
|
||||
using (var eng1 = Build(up, out var sharedStore))
|
||||
{
|
||||
up.Set("Temp", 150);
|
||||
await eng1.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
eng1.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
|
||||
await eng1.AcknowledgeAsync("HighTemp", "alice", null, TestContext.Current.CancellationToken);
|
||||
eng1.GetState("HighTemp")!.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
}
|
||||
|
||||
// Simulate restart — temp is back to 50 (below threshold).
|
||||
up.Set("Temp", 50);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var store2 = new InMemoryAlarmStateStore();
|
||||
// seed store2 with the acked state from before restart
|
||||
await store2.SaveAsync(new AlarmConditionState(
|
||||
"HighTemp",
|
||||
AlarmEnabledState.Enabled,
|
||||
AlarmActiveState.Active, // was active pre-restart
|
||||
AlarmAckedState.Acknowledged, // ack persisted
|
||||
AlarmConfirmedState.Unconfirmed,
|
||||
ShelvingState.Unshelved,
|
||||
DateTime.UtcNow,
|
||||
DateTime.UtcNow, null,
|
||||
DateTime.UtcNow, "alice", null,
|
||||
null, null, null,
|
||||
[new AlarmComment(DateTime.UtcNow, "alice", "Acknowledge", "")]),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
using var eng2 = new ScriptedAlarmEngine(up, store2, new ScriptLoggerFactory(logger), logger);
|
||||
await eng2.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var s = eng2.GetState("HighTemp")!;
|
||||
s.Active.ShouldBe(AlarmActiveState.Inactive, "Active recomputed from current tag value");
|
||||
s.Acked.ShouldBe(AlarmAckedState.Acknowledged, "Ack persisted across restart");
|
||||
s.LastAckUser.ShouldBe("alice");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shelved_active_transitions_state_but_suppresses_emission()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.OneShotShelveAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<ScriptedAlarmEvent>();
|
||||
eng.OnEvent += (_, e) => events.Add(e);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Any(e => e.Emission == EmissionKind.Activated).ShouldBeFalse(
|
||||
"OneShot shelve suppresses activation emission");
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Active,
|
||||
"state still advances so startup recovery is consistent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Predicate_runtime_exception_does_not_transition_state()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 150);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([
|
||||
Alarm("BadScript", """throw new InvalidOperationException("boom");"""),
|
||||
Alarm("GoodScript", """return (int)ctx.GetTag("Temp").Value > 100;"""),
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
// Bad script doesn't activate + doesn't disable other alarms.
|
||||
eng.GetState("BadScript")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
eng.GetState("GoodScript")!.Active.ShouldBe(AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disable_prevents_activation_until_re_enabled()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("HighTemp", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.DisableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(100);
|
||||
eng.GetState("HighTemp")!.Active.ShouldBe(AlarmActiveState.Inactive,
|
||||
"disabled alarm ignores predicate");
|
||||
|
||||
await eng.EnableAsync("HighTemp", "alice", TestContext.Current.CancellationToken);
|
||||
up.Push("Temp", 160);
|
||||
await WaitForAsync(() => eng.GetState("HighTemp")!.Active == AlarmActiveState.Active);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddComment_appends_to_audit_without_state_change()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
using var eng = Build(up, out var store);
|
||||
await eng.LoadAsync([Alarm("A", """return false;""")], TestContext.Current.CancellationToken);
|
||||
|
||||
await eng.AddCommentAsync("A", "alice", "peeking at this", TestContext.Current.CancellationToken);
|
||||
|
||||
var s = await store.LoadAsync("A", TestContext.Current.CancellationToken);
|
||||
s.ShouldNotBeNull();
|
||||
s!.Comments.Count.ShouldBe(1);
|
||||
s.Comments[0].User.ShouldBe("alice");
|
||||
s.Comments[0].Kind.ShouldBe("AddComment");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Predicate_scripts_cannot_SetVirtualTag()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 100);
|
||||
using var eng = Build(up, out _);
|
||||
|
||||
// The script compiles fine but throws at runtime when SetVirtualTag is called.
|
||||
// The engine swallows the exception + leaves state unchanged.
|
||||
await eng.LoadAsync([
|
||||
new ScriptedAlarmDefinition(
|
||||
"Bad", "Plant/Line1", "Bad",
|
||||
AlarmKind.AlarmCondition, AlarmSeverity.High, "bad",
|
||||
"""
|
||||
ctx.SetVirtualTag("NotAllowed", 1);
|
||||
return true;
|
||||
"""),
|
||||
], TestContext.Current.CancellationToken);
|
||||
|
||||
// Bad alarm's predicate threw — state unchanged.
|
||||
eng.GetState("Bad")!.Active.ShouldBe(AlarmActiveState.Inactive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_releases_upstream_subscriptions()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
var eng = Build(up, out _);
|
||||
await eng.LoadAsync([Alarm("A", """return (int)ctx.GetTag("Temp").Value > 100;""")],
|
||||
TestContext.Current.CancellationToken);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
eng.Dispose();
|
||||
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cond()) return;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
throw new TimeoutException("Condition did not become true in time");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptedAlarmSourceTests
|
||||
{
|
||||
private static async Task<(ScriptedAlarmEngine e, ScriptedAlarmSource s, FakeUpstream u)> BuildAsync()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("Temp", 50);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var engine = new ScriptedAlarmEngine(up, new InMemoryAlarmStateStore(),
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
await engine.LoadAsync([
|
||||
new ScriptedAlarmDefinition(
|
||||
"Plant/Line1::HighTemp",
|
||||
"Plant/Line1",
|
||||
"HighTemp",
|
||||
AlarmKind.LimitAlarm,
|
||||
AlarmSeverity.High,
|
||||
"Temp {Temp}C",
|
||||
"""return (int)ctx.GetTag("Temp").Value > 100;"""),
|
||||
new ScriptedAlarmDefinition(
|
||||
"Plant/Line2::OtherAlarm",
|
||||
"Plant/Line2",
|
||||
"OtherAlarm",
|
||||
AlarmKind.AlarmCondition,
|
||||
AlarmSeverity.Low,
|
||||
"other",
|
||||
"""return false;"""),
|
||||
], CancellationToken.None);
|
||||
|
||||
var source = new ScriptedAlarmSource(engine);
|
||||
return (engine, source, up);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_with_empty_filter_receives_every_alarm_emission()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].ConditionId.ShouldBe("Plant/Line1::HighTemp");
|
||||
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
||||
events[0].Severity.ShouldBe(AlarmSeverity.High);
|
||||
events[0].AlarmType.ShouldBe("LimitAlarm");
|
||||
events[0].Message.ShouldBe("Temp 150C");
|
||||
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_with_equipment_prefix_filters_by_that_prefix()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
|
||||
// Subscribe only to Line1 alarms.
|
||||
var handle = await source.SubscribeAlarmsAsync(["Plant/Line1"], TestContext.Current.CancellationToken);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].SourceNodeId.ShouldBe("Plant/Line1");
|
||||
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_further_events()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
source.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAlarmsAsync([], TestContext.Current.CancellationToken);
|
||||
await source.UnsubscribeAlarmsAsync(handle, TestContext.Current.CancellationToken);
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgeAsync_routes_to_engine_with_default_user()
|
||||
{
|
||||
var (engine, source, up) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
up.Push("Temp", 150);
|
||||
await Task.Delay(200);
|
||||
engine.GetState("Plant/Line1::HighTemp")!.Acked.ShouldBe(AlarmAckedState.Unacknowledged);
|
||||
|
||||
await source.AcknowledgeAsync([new AlarmAcknowledgeRequest(
|
||||
"Plant/Line1", "Plant/Line1::HighTemp", "ack via opcua")],
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var state = engine.GetState("Plant/Line1::HighTemp")!;
|
||||
state.Acked.ShouldBe(AlarmAckedState.Acknowledged);
|
||||
state.LastAckUser.ShouldBe("opcua-client");
|
||||
state.LastAckComment.ShouldBe("ack via opcua");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_arguments_rejected()
|
||||
{
|
||||
var (engine, source, _) = await BuildAsync();
|
||||
using var _e = engine;
|
||||
using var _s = source;
|
||||
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.SubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.UnsubscribeAlarmsAsync(null!, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.AcknowledgeAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms\ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,151 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the source-hash keyed compile cache. Roslyn compilation is the most
|
||||
/// expensive step in the evaluator pipeline; this cache collapses redundant
|
||||
/// compiles of unchanged scripts to zero-cost lookups + makes sure concurrent
|
||||
/// callers never double-compile.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CompiledScriptCacheTests
|
||||
{
|
||||
private sealed class CompileCountingGate
|
||||
{
|
||||
public int Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_call_compiles_and_caches()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.Count.ShouldBe(0);
|
||||
|
||||
var e = cache.GetOrCompile("""return 42;""");
|
||||
e.ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
cache.Contains("""return 42;""").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identical_source_returns_the_same_compiled_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var first = cache.GetOrCompile("""return 1;""");
|
||||
var second = cache.GetOrCompile("""return 1;""");
|
||||
ReferenceEquals(first, second).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_source_produces_different_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var a = cache.GetOrCompile("""return 1;""");
|
||||
var b = cache.GetOrCompile("""return 2;""");
|
||||
ReferenceEquals(a, b).ShouldBeFalse();
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Whitespace_difference_misses_cache()
|
||||
{
|
||||
// Documented behavior: reformatting a script recompiles. Simpler + cheaper
|
||||
// than the alternative (AST-canonicalize then hash) and doesn't happen often.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("return 1; "); // trailing whitespace — different hash
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cached_evaluator_still_runs_correctly()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, double>();
|
||||
var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""");
|
||||
var ctx = new FakeScriptContext().Seed("In", 7.0);
|
||||
|
||||
// Run twice through the cache — both must return the same correct value.
|
||||
var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""")
|
||||
.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
first.ShouldBe(21.0);
|
||||
second.ShouldBe(21.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
|
||||
// First attempt — undefined identifier, compile throws.
|
||||
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
||||
cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt");
|
||||
|
||||
// Retry with corrected source succeeds + caches.
|
||||
cache.GetOrCompile("""return 42;""").ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_drops_every_entry()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("""return 2;""");
|
||||
cache.Count.ShouldBe(2);
|
||||
|
||||
cache.Clear();
|
||||
cache.Count.ShouldBe(0);
|
||||
cache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrent_compiles_of_the_same_source_deduplicate()
|
||||
{
|
||||
// LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile
|
||||
// even when multiple threads race GetOrCompile against an empty cache.
|
||||
// We can't directly count Roslyn compilations — but we can assert all
|
||||
// concurrent callers see the same evaluator instance.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
const string src = """return 99;""";
|
||||
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => Task.Run(() => cache.GetOrCompile(src)))
|
||||
.ToArray();
|
||||
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||||
|
||||
var firstInstance = tasks[0].Result;
|
||||
foreach (var t in tasks)
|
||||
ReferenceEquals(t.Result, firstInstance).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_TContext_TResult_pairs_use_separate_cache_instances()
|
||||
{
|
||||
// Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns
|
||||
// its own cache. The type-parametric design makes this the default without
|
||||
// cross-contamination at the dictionary level.
|
||||
var intCache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();
|
||||
|
||||
intCache.GetOrCompile("""return 1;""");
|
||||
boolCache.GetOrCompile("""return true;""");
|
||||
|
||||
intCache.Count.ShouldBe(1);
|
||||
boolCache.Count.ShouldBe(1);
|
||||
intCache.Contains("""return true;""").ShouldBeFalse();
|
||||
boolCache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_source_throws_ArgumentNullException()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the sink that mirrors script Error+ events to the main log at Warning
|
||||
/// level. Ensures script noise (Debug/Info/Warning) doesn't reach the main log
|
||||
/// while genuine script failures DO surface there so operators see them without
|
||||
/// watching a separate log file.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLogCompanionSinkTests
|
||||
{
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
private static (ILogger script, CapturingSink scriptSink, CapturingSink mainSink) BuildPipeline()
|
||||
{
|
||||
// Main logger captures companion forwards.
|
||||
var mainSink = new CapturingSink();
|
||||
var mainLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||
|
||||
// Script logger fans out to scripts file (here: capture sink) + the companion sink.
|
||||
var scriptSink = new CapturingSink();
|
||||
var scriptLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(scriptSink)
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
|
||||
.CreateLogger();
|
||||
|
||||
return (scriptLogger, scriptSink, mainSink);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Info_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Information("just info");
|
||||
|
||||
scriptSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Warning_event_lands_in_scripts_sink_but_not_in_main()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Test").Warning("just a warning");
|
||||
|
||||
scriptSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_event_mirrored_to_main_at_Warning_level()
|
||||
{
|
||||
var (script, scriptSink, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "MyAlarm")
|
||||
.Error("condition script failed");
|
||||
|
||||
scriptSink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning, "Error+ is downgraded to Warning in the main log");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirrored_event_includes_ScriptName_and_original_level()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "HighTemp")
|
||||
.Error("temp exceeded limit");
|
||||
|
||||
var forwarded = mainSink.Events[0];
|
||||
forwarded.Properties.ShouldContainKey("ScriptName");
|
||||
((ScalarValue)forwarded.Properties["ScriptName"]).Value.ShouldBe("HighTemp");
|
||||
forwarded.Properties.ShouldContainKey("OriginalLevel");
|
||||
((ScalarValue)forwarded.Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mirrored_event_preserves_exception_for_main_log_stack_trace()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
var ex = new InvalidOperationException("user code threw");
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "BadScript").Error(ex, "boom");
|
||||
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Exception.ShouldBeSameAs(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fatal_event_mirrored_just_like_Error()
|
||||
{
|
||||
var (script, _, mainSink) = BuildPipeline();
|
||||
script.ForContext(ScriptLoggerFactory.ScriptNameProperty, "Fatal_Script").Fatal("catastrophic");
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_ScriptName_property_falls_back_to_unknown()
|
||||
{
|
||||
var (_, _, mainSink) = BuildPipeline();
|
||||
// Log without the ScriptName property to simulate a direct root-logger call
|
||||
// that bypassed the factory (defensive — shouldn't normally happen).
|
||||
var mainLogger = new LoggerConfiguration().CreateLogger();
|
||||
var companion = new ScriptLogCompanionSink(Log.Logger);
|
||||
|
||||
// Build an event manually so we can omit the property.
|
||||
var ev = new LogEvent(
|
||||
timestamp: DateTimeOffset.UtcNow,
|
||||
level: LogEventLevel.Error,
|
||||
exception: null,
|
||||
messageTemplate: new Serilog.Parsing.MessageTemplateParser().Parse("naked error"),
|
||||
properties: []);
|
||||
// Direct test: sink should not throw + message should be well-formed.
|
||||
Should.NotThrow(() => companion.Emit(ev));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_main_logger_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLogCompanionSink(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Custom_mirror_threshold_applied()
|
||||
{
|
||||
// Caller can raise the mirror threshold to Fatal if they want only
|
||||
// catastrophic events in the main log.
|
||||
var mainSink = new CapturingSink();
|
||||
var mainLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
|
||||
|
||||
var scriptLogger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger, LogEventLevel.Fatal))
|
||||
.CreateLogger();
|
||||
|
||||
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Error("error");
|
||||
mainSink.Events.Count.ShouldBe(0, "Error below configured Fatal threshold — not mirrored");
|
||||
|
||||
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Fatal("fatal");
|
||||
mainSink.Events.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the factory that creates per-script Serilog loggers with the
|
||||
/// <c>ScriptName</c> structured property pre-bound. The property is what lets
|
||||
/// Admin UI filter the scripts-*.log sink by which tag/alarm emitted each event.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptLoggerFactoryTests
|
||||
{
|
||||
/// <summary>Capturing sink that collects every emitted LogEvent for assertion.</summary>
|
||||
private sealed class CapturingSink : ILogEventSink
|
||||
{
|
||||
public List<LogEvent> Events { get; } = [];
|
||||
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_sets_ScriptName_structured_property()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
var logger = factory.Create("LineRate");
|
||||
logger.Information("hello");
|
||||
|
||||
sink.Events.Count.ShouldBe(1);
|
||||
var ev = sink.Events[0];
|
||||
ev.Properties.ShouldContainKey(ScriptLoggerFactory.ScriptNameProperty);
|
||||
((ScalarValue)ev.Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("LineRate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Each_script_gets_its_own_property_value()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
factory.Create("Alarm_A").Information("event A");
|
||||
factory.Create("Tag_B").Warning("event B");
|
||||
factory.Create("Alarm_A").Error("event A again");
|
||||
|
||||
sink.Events.Count.ShouldBe(3);
|
||||
((ScalarValue)sink.Events[0].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||
((ScalarValue)sink.Events[1].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Tag_B");
|
||||
((ScalarValue)sink.Events[2].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("Alarm_A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Error_level_event_preserves_level_and_exception()
|
||||
{
|
||||
var sink = new CapturingSink();
|
||||
var root = new LoggerConfiguration().MinimumLevel.Verbose().WriteTo.Sink(sink).CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
|
||||
factory.Create("Test").Error(new InvalidOperationException("boom"), "script failed");
|
||||
|
||||
sink.Events[0].Level.ShouldBe(LogEventLevel.Error);
|
||||
sink.Events[0].Exception.ShouldBeOfType<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_root_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() => new ScriptLoggerFactory(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_script_name_rejected()
|
||||
{
|
||||
var root = new LoggerConfiguration().CreateLogger();
|
||||
var factory = new ScriptLoggerFactory(root);
|
||||
Should.Throw<ArgumentException>(() => factory.Create(""));
|
||||
Should.Throw<ArgumentException>(() => factory.Create(" "));
|
||||
Should.Throw<ArgumentException>(() => factory.Create(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptNameProperty_constant_is_stable()
|
||||
{
|
||||
// Stability is an external contract — the Admin UI's log filter references
|
||||
// this exact string. If it changes, the filter breaks silently.
|
||||
ScriptLoggerFactory.ScriptNameProperty.ShouldBe("ScriptName");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-evaluation timeout wrapper. Fast scripts complete normally;
|
||||
/// CPU-bound or hung scripts throw <see cref="ScriptTimeoutException"/> instead of
|
||||
/// starving the engine. Caller-supplied cancellation tokens take precedence over the
|
||||
/// timeout so driver-shutdown paths see a clean cancel rather than a timeout.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimedScriptEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Fast_script_completes_under_timeout_and_returns_value()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""return (double)ctx.GetTag("In").Value + 1.0;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, double>(
|
||||
inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ctx = new FakeScriptContext().Seed("In", 41.0);
|
||||
var result = await timed.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
|
||||
{
|
||||
// Scripts can't easily do Thread.Sleep in the sandbox (System.Threading.Thread
|
||||
// is denied). But a tight CPU loop exceeds any short timeout.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Timeout.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||
ex.Message.ShouldContain("50.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caller_cancellation_takes_precedence_over_timeout()
|
||||
{
|
||||
// A CPU-bound script that would otherwise timeout; external ct fires first.
|
||||
// Expected: OperationCanceledException (not ScriptTimeoutException) so shutdown
|
||||
// paths aren't misclassified as timeouts.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 10000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromSeconds(5));
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_timeout_is_250ms_per_plan()
|
||||
{
|
||||
TimedScriptEvaluator<FakeScriptContext, int>.DefaultTimeout
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_or_negative_timeout_is_rejected_at_construction()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.Zero));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromMilliseconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_inner_is_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_context_is_rejected()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner);
|
||||
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await timed.RunAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
// User-thrown exceptions must come through as-is — NOT wrapped in
|
||||
// ScriptTimeoutException. The virtual-tag engine catches them per-tag and
|
||||
// maps to BadInternalError; conflating with timeout would lose that info.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""throw new InvalidOperationException("script boom");""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldBe("script boom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(30));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ctx.Logger");
|
||||
ex.Message.ShouldContain("widening the timeout");
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,117 @@ public sealed class EquipmentNodeWalkerTests
|
||||
variable.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_VirtualTag_Variables_With_Virtual_Source_Discriminator()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var vtag = new VirtualTag
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "LineRate",
|
||||
DataType = "Float32", ScriptId = "scr-1", Historize = true,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], VirtualTags: [vtag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var equipmentNode = rec.Children[0].Children[0].Children[0];
|
||||
var v = equipmentNode.Variables.Single(x => x.BrowseName == "LineRate");
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Virtual);
|
||||
v.AttributeInfo.VirtualTagId.ShouldBe("vt-1");
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||
v.AttributeInfo.IsHistorized.ShouldBeTrue();
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Float32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Emits_ScriptedAlarm_Variables_With_ScriptedAlarm_Source_And_IsAlarm()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "HighTemp",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "{Temp} exceeded",
|
||||
PredicateScriptId = "scr-9", Severity = 800,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], ScriptedAlarms: [alarm]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var v = rec.Children[0].Children[0].Children[0].Variables.Single(x => x.BrowseName == "HighTemp");
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.ScriptedAlarm);
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBe("al-1");
|
||||
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||
v.AttributeInfo.IsAlarm.ShouldBeTrue();
|
||||
v.AttributeInfo.DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Skips_Disabled_VirtualTags_And_Alarms()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var vtag = new VirtualTag
|
||||
{
|
||||
VirtualTagRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "Disabled",
|
||||
DataType = "Float32", ScriptId = "scr-1", Enabled = false,
|
||||
};
|
||||
var alarm = new ScriptedAlarm
|
||||
{
|
||||
ScriptedAlarmRowId = Guid.NewGuid(), GenerationId = 1,
|
||||
ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "DisabledAlarm",
|
||||
AlarmType = "LimitAlarm", MessageTemplate = "x",
|
||||
PredicateScriptId = "scr-9", Enabled = false,
|
||||
};
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [], VirtualTags: [vtag], ScriptedAlarms: [alarm]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Walk_Null_VirtualTags_And_ScriptedAlarms_Is_Safe()
|
||||
{
|
||||
// Backwards-compat — callers that don't populate the new collections still work.
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")], [eq], []);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content); // must not throw
|
||||
|
||||
rec.Children[0].Children[0].Children[0].Variables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_tag_default_NodeSourceKind_is_Driver()
|
||||
{
|
||||
var eq = Eq("eq-1", "line-1", "oven-3");
|
||||
var tag = NewTag("t-1", "Temp", "Int32", "plc-01", "eq-1");
|
||||
var content = new EquipmentNamespaceContent(
|
||||
[Area("area-1", "warsaw")], [Line("line-1", "area-1", "line-a")],
|
||||
[eq], [tag]);
|
||||
|
||||
var rec = new RecordingBuilder("root");
|
||||
EquipmentNodeWalker.Walk(rec, content);
|
||||
|
||||
var v = rec.Children[0].Children[0].Children[0].Variables.Single();
|
||||
v.AttributeInfo.Source.ShouldBe(NodeSourceKind.Driver);
|
||||
v.AttributeInfo.VirtualTagId.ShouldBeNull();
|
||||
v.AttributeInfo.ScriptedAlarmId.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- builders for test seed rows -----
|
||||
|
||||
private static UnsArea Area(string id, string name) => new()
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cycle detection + topological sort on the virtual-tag dependency
|
||||
/// graph. Publish-time correctness depends on these being right — a missed cycle
|
||||
/// would deadlock cascade evaluation; a wrong topological order would miscompute
|
||||
/// chained virtual tags.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DependencyGraphTests
|
||||
{
|
||||
private static IReadOnlySet<string> Set(params string[] items) =>
|
||||
new HashSet<string>(items, StringComparer.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Empty_graph_produces_empty_sort_and_no_cycles()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.TopologicalSort().ShouldBeEmpty();
|
||||
g.DetectCycles().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_node_with_no_deps()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set());
|
||||
g.TopologicalSort().ShouldBe(new[] { "A" });
|
||||
g.DetectCycles().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Topological_order_places_dependencies_before_dependents()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A")); // B depends on A
|
||||
g.Add("C", Set("B", "A")); // C depends on B + A
|
||||
g.Add("A", Set()); // A is a leaf
|
||||
|
||||
var order = g.TopologicalSort();
|
||||
var idx = order.Select((x, i) => (x, i)).ToDictionary(p => p.x, p => p.i);
|
||||
idx["A"].ShouldBeLessThan(idx["B"]);
|
||||
idx["B"].ShouldBeLessThan(idx["C"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Self_loop_detected_as_cycle()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("A"));
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(1);
|
||||
cycles[0].ShouldContain("A");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Two_node_cycle_detected()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("A"));
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(1);
|
||||
cycles[0].Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Three_node_cycle_detected()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("C"));
|
||||
g.Add("C", Set("A"));
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(1);
|
||||
cycles[0].Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_disjoint_cycles_all_reported()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
// Cycle 1: A -> B -> A
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("A"));
|
||||
// Cycle 2: X -> Y -> Z -> X
|
||||
g.Add("X", Set("Y"));
|
||||
g.Add("Y", Set("Z"));
|
||||
g.Add("Z", Set("X"));
|
||||
// Clean leaf: M
|
||||
g.Add("M", Set());
|
||||
|
||||
var cycles = g.DetectCycles();
|
||||
cycles.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Topological_sort_throws_DependencyCycleException_on_cycle()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("A", Set("B"));
|
||||
g.Add("B", Set("A"));
|
||||
Should.Throw<DependencyCycleException>(() => g.TopologicalSort())
|
||||
.Cycles.ShouldNotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DirectDependents_returns_direct_only()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A"));
|
||||
g.Add("C", Set("B"));
|
||||
g.DirectDependents("A").ShouldBe(new[] { "B" });
|
||||
g.DirectDependents("B").ShouldBe(new[] { "C" });
|
||||
g.DirectDependents("C").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TransitiveDependentsInOrder_returns_topological_closure()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A"));
|
||||
g.Add("C", Set("B"));
|
||||
g.Add("D", Set("C"));
|
||||
var closure = g.TransitiveDependentsInOrder("A");
|
||||
closure.ShouldBe(new[] { "B", "C", "D" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Readding_a_node_overwrites_prior_dependencies()
|
||||
{
|
||||
var g = new DependencyGraph();
|
||||
g.Add("X", Set("A"));
|
||||
g.DirectDependencies("X").ShouldBe(new[] { "A" });
|
||||
// Re-add with different deps (simulates script edit + republish).
|
||||
g.Add("X", Set("B", "C"));
|
||||
g.DirectDependencies("X").OrderBy(s => s).ShouldBe(new[] { "B", "C" });
|
||||
// A should no longer list X as a dependent.
|
||||
g.DirectDependents("A").ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leaf_dependencies_not_registered_as_nodes_are_treated_as_implicit()
|
||||
{
|
||||
// A is referenced but never Add'd as a node — it's an upstream driver tag.
|
||||
var g = new DependencyGraph();
|
||||
g.Add("B", Set("A"));
|
||||
g.TopologicalSort().ShouldBe(new[] { "B" });
|
||||
g.DirectDependents("A").ShouldBe(new[] { "B" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deep_graph_no_stack_overflow()
|
||||
{
|
||||
// Iterative Tarjan's + Kahn's — 10k deep chain must complete without blowing the stack.
|
||||
var g = new DependencyGraph();
|
||||
for (var i = 1; i < 10_000; i++)
|
||||
g.Add($"N{i}", Set($"N{i - 1}"));
|
||||
var order = g.TopologicalSort();
|
||||
order.Count.ShouldBe(9_999);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ITagUpstreamSource"/> for tests. Seed tag values via
|
||||
/// <see cref="Set"/>, push changes via <see cref="Push"/>. Tracks subscriptions so
|
||||
/// tests can assert the engine disposes them on reload / shutdown.
|
||||
/// </summary>
|
||||
public sealed class FakeUpstream : ITagUpstreamSource
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs = new(StringComparer.Ordinal);
|
||||
|
||||
public int ActiveSubscriptionCount { get; private set; }
|
||||
|
||||
public void Set(string path, object value, uint statusCode = 0u)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||
}
|
||||
|
||||
public void Push(string path, object value, uint statusCode = 0u)
|
||||
{
|
||||
Set(path, value, statusCode);
|
||||
if (_subs.TryGetValue(path, out var list))
|
||||
{
|
||||
Action<string, DataValueSnapshot>[] snap;
|
||||
lock (list) { snap = list.ToArray(); }
|
||||
foreach (var obs in snap) obs(path, _values[path]);
|
||||
}
|
||||
}
|
||||
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
=> _values.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
var list = _subs.GetOrAdd(path, _ => []);
|
||||
lock (list) { list.Add(observer); }
|
||||
ActiveSubscriptionCount++;
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
private sealed class Unsub : IDisposable
|
||||
{
|
||||
private readonly FakeUpstream _up;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
_up = up; _path = path; _observer = observer;
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
if (_up._subs.TryGetValue(_path, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
if (list.Remove(_observer))
|
||||
_up.ActiveSubscriptionCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimerTriggerSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Timer_interval_causes_periodic_reevaluation()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
// Counter source — re-eval should pick up new value each tick.
|
||||
var counter = 0;
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger),
|
||||
logger);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Counter", DriverDataType.Int32,
|
||||
"""return ctx.Now.Millisecond;""", // changes on every evaluation
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"Counter", DriverDataType.Int32,
|
||||
"""return ctx.Now.Millisecond;""",
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||
|
||||
// Watch the value change across ticks.
|
||||
var snapshots = new List<object?>();
|
||||
using var sub = engine.Subscribe("Counter", (_, v) => snapshots.Add(v.Value));
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
snapshots.Count.ShouldBeGreaterThanOrEqualTo(3, "At least 3 ticks in 500ms at 100ms cadence");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tags_without_TimerInterval_not_scheduled()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
var events = new List<int>();
|
||||
using var sub = engine.Subscribe("NoTimer", (_, v) => events.Add((int)(v.Value ?? 0)));
|
||||
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(0, "No TimerInterval = no timer ticks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Start_groups_tags_by_interval_into_shared_timers()
|
||||
{
|
||||
// Smoke test — Start on a definition list with two distinct intervals must not
|
||||
// throw. Group count matches unique intervals.
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(5)),
|
||||
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
Should.NotThrow(() => sched.Start(new[]
|
||||
{
|
||||
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""", TimerInterval: TimeSpan.FromSeconds(5)),
|
||||
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposed_scheduler_stops_firing()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||
|
||||
var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||
sched.Dispose();
|
||||
|
||||
// After dispose, second Start throws ObjectDisposedException.
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end VirtualTagEngine behavior: load config, subscribe to upstream,
|
||||
/// evaluate on change, cascade through dependent virtual tags, timer-driven
|
||||
/// re-evaluation, error isolation, historize flag, cycle rejection.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VirtualTagEngineTests
|
||||
{
|
||||
private static VirtualTagEngine Build(
|
||||
FakeUpstream upstream,
|
||||
IHistoryWriter? history = null,
|
||||
TimeSpan? scriptTimeout = null,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
var rootLogger = new LoggerConfiguration().CreateLogger();
|
||||
return new VirtualTagEngine(
|
||||
upstream,
|
||||
new ScriptLoggerFactory(rootLogger),
|
||||
rootLogger,
|
||||
history,
|
||||
clock,
|
||||
scriptTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Simple_script_reads_upstream_and_returns_coerced_value()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("InTag", 10.0);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
Path: "LineRate",
|
||||
DataType: DriverDataType.Float64,
|
||||
ScriptSource: """return (double)ctx.GetTag("InTag").Value * 2.0;""")]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var result = engine.Read("LineRate");
|
||||
result.StatusCode.ShouldBe(0u);
|
||||
result.Value.ShouldBe(20.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upstream_change_triggers_cascade_through_two_levels()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1.0);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("B", DriverDataType.Float64,
|
||||
"""return (double)ctx.GetTag("A").Value + 10.0;"""),
|
||||
new VirtualTagDefinition("C", DriverDataType.Float64,
|
||||
"""return (double)ctx.GetTag("B").Value * 2.0;"""),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("B").Value.ShouldBe(11.0);
|
||||
engine.Read("C").Value.ShouldBe(22.0);
|
||||
|
||||
// Change upstream — cascade should recompute B (11→15.0) then C (30.0)
|
||||
up.Push("A", 5.0);
|
||||
await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
|
||||
engine.Read("B").Value.ShouldBe(15.0);
|
||||
engine.Read("C").Value.ShouldBe(30.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cycle_in_virtual_tags_rejected_at_Load()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
Should.Throw<DependencyCycleException>(() => engine.Load([
|
||||
new VirtualTagDefinition("A", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value + 1;"""),
|
||||
new VirtualTagDefinition("B", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value + 1;"""),
|
||||
]));
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_compile_error_surfaces_at_Load_with_all_failures()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("A", DriverDataType.Int32, """return undefinedIdentifier;"""),
|
||||
new VirtualTagDefinition("B", DriverDataType.Int32, """return 42;"""),
|
||||
new VirtualTagDefinition("C", DriverDataType.Int32, """var x = anotherUndefined; return x;"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("2 script(s) did not compile");
|
||||
ex.Message.ShouldContain("A");
|
||||
ex.Message.ShouldContain("C");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_runtime_exception_isolates_to_owning_tag()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("OK", 10);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("GoodTag", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("OK").Value * 2;"""),
|
||||
new VirtualTagDefinition("BadTag", DriverDataType.Int32,
|
||||
"""throw new InvalidOperationException("boom");"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("GoodTag").StatusCode.ShouldBe(0u);
|
||||
engine.Read("GoodTag").Value.ShouldBe(20);
|
||||
engine.Read("BadTag").StatusCode.ShouldBe(0x80020000u, "BadInternalError for thrown script");
|
||||
engine.Read("BadTag").Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timeout_maps_to_BadInternalError_without_killing_the_engine()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up, scriptTimeout: TimeSpan.FromMilliseconds(30));
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Hang", DriverDataType.Int32, """
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
"""),
|
||||
new VirtualTagDefinition("Ok", DriverDataType.Int32, """return 42;"""),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Hang").StatusCode.ShouldBe(0x80020000u);
|
||||
engine.Read("Ok").Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribers_receive_engine_emitted_changes()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value + 100;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var received = new List<DataValueSnapshot>();
|
||||
using var sub = engine.Subscribe("Out", (p, v) => received.Add(v));
|
||||
|
||||
up.Push("In", 5);
|
||||
await WaitForConditionAsync(() => received.Count >= 1);
|
||||
|
||||
received[^1].Value.ShouldBe(105);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Historize_flag_routes_to_history_writer()
|
||||
{
|
||||
var recorded = new List<(string, DataValueSnapshot)>();
|
||||
var history = new TestHistory(recorded);
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up, history);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("H", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value + 1;""", Historize: true),
|
||||
new VirtualTagDefinition("NoH", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value - 1;""", Historize: false),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
recorded.Select(p => p.Item1).ShouldContain("H");
|
||||
recorded.Select(p => p.Item1).ShouldNotContain("NoH");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Change_driven_false_ignores_upstream_push()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Manual", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value * 10;""",
|
||||
ChangeTriggered: false)]);
|
||||
|
||||
// Initial eval seeds the value.
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Manual").Value.ShouldBe(10);
|
||||
|
||||
// Upstream change fires but change-driven is off — no recompute.
|
||||
up.Push("In", 99);
|
||||
await Task.Delay(100);
|
||||
engine.Read("Manual").Value.ShouldBe(10, "change-driven=false ignores upstream deltas");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reload_replaces_existing_tags_and_resubscribes_cleanly()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1);
|
||||
up.Set("B", 2);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value * 2;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("T").Value.ShouldBe(2);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
// Reload — T now depends on B instead of A.
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value * 3;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("T").Value.ShouldBe(6);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1, "previous subscription on A must be disposed");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Dispose_releases_upstream_subscriptions()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1);
|
||||
var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value;""")]);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
engine.Dispose();
|
||||
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_within_script_updates_target_and_triggers_observers()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 5);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||
"""return 0;""", ChangeTriggered: false), // placeholder value, operator-written via SetVirtualTag
|
||||
new VirtualTagDefinition("Driver", DriverDataType.Int32,
|
||||
"""
|
||||
var v = (int)ctx.GetTag("In").Value;
|
||||
ctx.SetVirtualTag("Target", v * 100);
|
||||
return v;
|
||||
"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("Target").Value.ShouldBe(500);
|
||||
engine.Read("Driver").Value.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Type_coercion_from_script_double_to_config_int32()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 3.7);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Rounded", DriverDataType.Int32,
|
||||
"""return (double)ctx.GetTag("In").Value;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("Rounded").Value.ShouldBe(4, "Convert.ToInt32 rounds 3.7 to 4");
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cond()) return;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
throw new TimeoutException("Condition did not become true in time");
|
||||
}
|
||||
|
||||
private sealed class TestHistory : IHistoryWriter
|
||||
{
|
||||
private readonly List<(string, DataValueSnapshot)> _buf;
|
||||
public TestHistory(List<(string, DataValueSnapshot)> buf) => _buf = buf;
|
||||
public void Record(string path, DataValueSnapshot value)
|
||||
{
|
||||
lock (_buf) { _buf.Add((path, value)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the IReadable + ISubscribable adapter that DriverNodeManager dispatches
|
||||
/// to for NodeSource.Virtual per ADR-002. Key contract: OPC UA clients see virtual
|
||||
/// tags via the same capability interfaces as driver tags, so dispatch stays
|
||||
/// source-agnostic.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VirtualTagSourceTests
|
||||
{
|
||||
private static (VirtualTagEngine engine, VirtualTagSource source, FakeUpstream up) Build()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 10);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var engine = new VirtualTagEngine(up, new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value * 2;""")]);
|
||||
return (engine, new VirtualTagSource(engine), up);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_engine_cached_values()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await source.ReadAsync(["Out"], TestContext.Current.CancellationToken);
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].Value.ShouldBe(20);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_unknown_path_returns_Bad_quality()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
var results = await source.ReadAsync(["NoSuchTag"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x80340000u);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_fires_initial_data_callback()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.FromMilliseconds(100),
|
||||
TestContext.Current.CancellationToken);
|
||||
handle.ShouldNotBeNull();
|
||||
|
||||
// Per OPC UA convention, initial-data callback fires on subscribe.
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
events[0].FullReference.ShouldBe("Out");
|
||||
events[0].Snapshot.Value.ShouldBe(20);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_fires_on_upstream_change_via_engine_cascade()
|
||||
{
|
||||
var (engine, source, up) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var initialCount = events.Count;
|
||||
up.Push("In", 50);
|
||||
|
||||
// Wait for the cascade.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline && events.Count <= initialCount) await Task.Delay(25);
|
||||
|
||||
events.Count.ShouldBeGreaterThan(initialCount);
|
||||
events[^1].Snapshot.Value.ShouldBe(100);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_stops_further_events()
|
||||
{
|
||||
var (engine, source, up) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
var countAfterUnsub = events.Count;
|
||||
|
||||
up.Push("In", 99);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(countAfterUnsub, "Unsubscribe must stop OnDataChange emissions");
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_arguments_rejected()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.ReadAsync(null!, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.SubscribeAsync(null!, TimeSpan.Zero, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.UnsubscribeAsync(null!, TestContext.Current.CancellationToken));
|
||||
engine.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.VirtualTags\ZB.MOM.WW.OtOpcUa.Core.VirtualTags.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user