Notify.To(list).Send(subject,body) now generates a NotificationId GUID, enqueues a Notification-category message into the site Store-and-Forward Engine, and returns the NotificationId immediately (Task<string>). The NotificationId is the single idempotency key end-to-end: it is the S&F message Id, it is carried inside the buffered NotificationSubmit payload, and it is the id the forwarder submits to central. NotificationForwarder now deserializes the buffered payload as a NotificationSubmit and reads NotificationId from it (re-stamping only the site-owned SourceSiteId / SourceInstanceId), instead of deriving the id from StoreAndForwardMessage.Id. Adds NotifyHelper.Status(id): queries central via the site communication actor; reports the site-local Forwarding state while the notification is still buffered at the site, maps central's response when found, and Unknown otherwise. Adds a NotificationDeliveryStatus record. SiteCommunicationActor gains a NotificationStatusQuery forwarding handler mirroring NotificationSubmit. StoreAndForwardService.EnqueueAsync gains an optional messageId parameter and exposes GetMessageByIdAsync.
547 lines
22 KiB
C#
547 lines
22 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Microsoft.Extensions.Logging;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.Instance;
|
|
using ScadaLink.Commons.Messages.Notification;
|
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
|
using ScadaLink.Commons.Types;
|
|
using ScadaLink.Commons.Types.Enums;
|
|
using ScadaLink.StoreAndForward;
|
|
|
|
namespace ScadaLink.SiteRuntime.Scripts;
|
|
|
|
/// <summary>
|
|
/// WP-18: Script Runtime API — injected into Script/Alarm Execution Actors.
|
|
/// Provides the API surface that user scripts interact with:
|
|
/// Instance.GetAttribute("name")
|
|
/// Instance.SetAttribute("name", value)
|
|
/// Instance.CallScript("scriptName", params)
|
|
/// Scripts.CallShared("scriptName", params)
|
|
///
|
|
/// WP-13 (Phase 7): Integration surface APIs:
|
|
/// ExternalSystem.Call("systemName", "methodName", params)
|
|
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
|
/// Database.Connection("name")
|
|
/// Database.CachedWrite("name", "sql", params)
|
|
/// Notify.To("listName").Send("subject", "message")
|
|
///
|
|
/// WP-20: Recursion Limit — call depth tracked and enforced.
|
|
/// </summary>
|
|
public class ScriptRuntimeContext
|
|
{
|
|
private readonly IActorRef _instanceActor;
|
|
private readonly IActorRef _self;
|
|
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
|
private readonly int _currentCallDepth;
|
|
private readonly int _maxCallDepth;
|
|
private readonly TimeSpan _askTimeout;
|
|
private readonly ILogger _logger;
|
|
private readonly string _instanceName;
|
|
|
|
/// <summary>
|
|
/// WP-13: External system client for ExternalSystem.Call/CachedCall.
|
|
/// </summary>
|
|
private readonly IExternalSystemClient? _externalSystemClient;
|
|
|
|
/// <summary>
|
|
/// WP-13: Database gateway for Database.Connection/CachedWrite.
|
|
/// </summary>
|
|
private readonly IDatabaseGateway? _databaseGateway;
|
|
|
|
/// <summary>
|
|
/// Notification Outbox: the site Store-and-Forward Engine that <c>Notify.Send</c>
|
|
/// enqueues notifications into. The S&F engine forwards them to central.
|
|
/// </summary>
|
|
private readonly StoreAndForwardService? _storeAndForward;
|
|
|
|
/// <summary>
|
|
/// Notification Outbox: the site communication actor that <c>Notify.Status</c>
|
|
/// queries central through (via the ClusterClient command/control transport).
|
|
/// </summary>
|
|
private readonly ICanTell? _siteCommunicationActor;
|
|
|
|
/// <summary>
|
|
/// Notification Outbox: this site's identifier, stamped on enqueued notifications.
|
|
/// </summary>
|
|
private readonly string _siteId;
|
|
|
|
public ScriptRuntimeContext(
|
|
IActorRef instanceActor,
|
|
IActorRef self,
|
|
SharedScriptLibrary sharedScriptLibrary,
|
|
int currentCallDepth,
|
|
int maxCallDepth,
|
|
TimeSpan askTimeout,
|
|
string instanceName,
|
|
ILogger logger,
|
|
IExternalSystemClient? externalSystemClient = null,
|
|
IDatabaseGateway? databaseGateway = null,
|
|
StoreAndForwardService? storeAndForward = null,
|
|
ICanTell? siteCommunicationActor = null,
|
|
string siteId = "")
|
|
{
|
|
_instanceActor = instanceActor;
|
|
_self = self;
|
|
_sharedScriptLibrary = sharedScriptLibrary;
|
|
_currentCallDepth = currentCallDepth;
|
|
_maxCallDepth = maxCallDepth;
|
|
_askTimeout = askTimeout;
|
|
_instanceName = instanceName;
|
|
_logger = logger;
|
|
_externalSystemClient = externalSystemClient;
|
|
_databaseGateway = databaseGateway;
|
|
_storeAndForward = storeAndForward;
|
|
_siteCommunicationActor = siteCommunicationActor;
|
|
_siteId = siteId;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current value of an attribute from the Instance Actor.
|
|
/// Uses Ask pattern (system boundary between script execution and instance state).
|
|
/// </summary>
|
|
public async Task<object?> GetAttribute(string attributeName)
|
|
{
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
var request = new GetAttributeRequest(
|
|
correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow);
|
|
|
|
var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout);
|
|
|
|
if (!response.Found)
|
|
{
|
|
_logger.LogWarning(
|
|
"GetAttribute: attribute '{Attribute}' not found on instance '{Instance}'",
|
|
attributeName, _instanceName);
|
|
}
|
|
|
|
return response.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets an attribute value. For data-connected attributes the Instance Actor
|
|
/// forwards the write to the DCL, which writes the physical device; the
|
|
/// in-memory value is not optimistically updated. For static attributes the
|
|
/// Instance Actor updates the in-memory value and persists the override to
|
|
/// SQLite. All mutations are serialized through the Instance Actor mailbox.
|
|
///
|
|
/// The write is awaited so that a device-write failure on a data-connected
|
|
/// attribute is surfaced synchronously to the calling script as an
|
|
/// <see cref="InvalidOperationException"/>.
|
|
/// </summary>
|
|
public async Task SetAttribute(string attributeName, string value)
|
|
{
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
var command = new SetStaticAttributeCommand(
|
|
correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow);
|
|
|
|
// Ask — mutation serialized through the Instance Actor mailbox; the reply
|
|
// carries the device-write outcome for data-connected attributes.
|
|
var response = await _instanceActor.Ask<SetStaticAttributeResponse>(command, _askTimeout);
|
|
|
|
if (!response.Success)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"SetAttribute('{attributeName}') failed: {response.ErrorMessage}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calls a sibling script on the same instance by name (Ask pattern).
|
|
/// WP-20: Enforces recursion limit.
|
|
/// WP-22: Uses Ask pattern for CallScript.
|
|
/// <paramref name="parameters"/> may be a dictionary or an anonymous object
|
|
/// (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
|
|
/// </summary>
|
|
public async Task<object?> CallScript(string scriptName, object? parameters = null)
|
|
{
|
|
var nextDepth = _currentCallDepth + 1;
|
|
if (nextDepth > _maxCallDepth)
|
|
{
|
|
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
|
$"CallScript('{scriptName}') rejected at depth {nextDepth}.";
|
|
_logger.LogError(msg);
|
|
throw new InvalidOperationException(msg);
|
|
}
|
|
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
var request = new ScriptCallRequest(
|
|
scriptName,
|
|
ScriptArgs.Normalize(parameters),
|
|
nextDepth,
|
|
correlationId);
|
|
|
|
// Ask the Instance Actor, which routes to the appropriate Script Actor
|
|
var result = await _instanceActor.Ask<ScriptCallResult>(request, _askTimeout);
|
|
|
|
if (!result.Success)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"CallScript('{scriptName}') failed: {result.ErrorMessage}");
|
|
}
|
|
|
|
return result.ReturnValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Provides access to shared script execution via the Scripts property.
|
|
/// </summary>
|
|
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
|
|
|
/// <summary>
|
|
/// WP-13: Provides access to external system calls.
|
|
/// ExternalSystem.Call("systemName", "methodName", params)
|
|
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
|
/// </summary>
|
|
public ExternalSystemHelper ExternalSystem => new(_externalSystemClient, _instanceName, _logger);
|
|
|
|
/// <summary>
|
|
/// WP-13: Provides access to database operations.
|
|
/// Database.Connection("name")
|
|
/// Database.CachedWrite("name", "sql", params)
|
|
/// </summary>
|
|
public DatabaseHelper Database => new(_databaseGateway, _instanceName, _logger);
|
|
|
|
/// <summary>
|
|
/// Provides access to the Notification Outbox API.
|
|
/// <c>Notify.To("listName").Send("subject", "message")</c> enqueues a notification
|
|
/// for central delivery and returns its <c>NotificationId</c>;
|
|
/// <c>Notify.Status(id)</c> queries the delivery status of that notification.
|
|
/// </summary>
|
|
public NotifyHelper Notify => new(
|
|
_storeAndForward, _siteCommunicationActor, _siteId, _instanceName, _askTimeout, _logger);
|
|
|
|
/// <summary>
|
|
/// Helper class for Scripts.CallShared() syntax.
|
|
/// </summary>
|
|
public class ScriptCallHelper
|
|
{
|
|
private readonly SharedScriptLibrary _library;
|
|
private readonly ScriptRuntimeContext _context;
|
|
private readonly int _currentCallDepth;
|
|
private readonly int _maxCallDepth;
|
|
private readonly ILogger _logger;
|
|
|
|
internal ScriptCallHelper(
|
|
SharedScriptLibrary library,
|
|
ScriptRuntimeContext context,
|
|
int currentCallDepth,
|
|
int maxCallDepth,
|
|
ILogger logger)
|
|
{
|
|
_library = library;
|
|
_context = context;
|
|
_currentCallDepth = currentCallDepth;
|
|
_maxCallDepth = maxCallDepth;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-17: Executes a shared script inline (direct method call, not actor message).
|
|
/// WP-20: Enforces recursion limit.
|
|
/// <paramref name="parameters"/> may be a dictionary or an anonymous
|
|
/// object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
|
|
/// </summary>
|
|
public async Task<object?> CallShared(
|
|
string scriptName,
|
|
object? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var nextDepth = _currentCallDepth + 1;
|
|
if (nextDepth > _maxCallDepth)
|
|
{
|
|
var msg = $"Script call depth exceeded maximum of {_maxCallDepth}. " +
|
|
$"CallShared('{scriptName}') rejected at depth {nextDepth}.";
|
|
_logger.LogError(msg);
|
|
throw new InvalidOperationException(msg);
|
|
}
|
|
|
|
return await _library.ExecuteAsync(
|
|
scriptName, _context, ScriptArgs.Normalize(parameters), cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-13: Helper for ExternalSystem.Call/CachedCall syntax.
|
|
/// </summary>
|
|
public class ExternalSystemHelper
|
|
{
|
|
private readonly IExternalSystemClient? _client;
|
|
private readonly string _instanceName;
|
|
private readonly ILogger _logger;
|
|
|
|
internal ExternalSystemHelper(IExternalSystemClient? client, string instanceName, ILogger logger)
|
|
{
|
|
_client = client;
|
|
_instanceName = instanceName;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<ExternalCallResult> Call(
|
|
string systemName,
|
|
string methodName,
|
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_client == null)
|
|
throw new InvalidOperationException("External system client not available");
|
|
|
|
return await _client.CallAsync(systemName, methodName, parameters, cancellationToken);
|
|
}
|
|
|
|
public async Task<ExternalCallResult> CachedCall(
|
|
string systemName,
|
|
string methodName,
|
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_client == null)
|
|
throw new InvalidOperationException("External system client not available");
|
|
|
|
return await _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-13: Helper for Database.Connection/CachedWrite syntax.
|
|
/// </summary>
|
|
public class DatabaseHelper
|
|
{
|
|
private readonly IDatabaseGateway? _gateway;
|
|
private readonly string _instanceName;
|
|
private readonly ILogger _logger;
|
|
|
|
internal DatabaseHelper(IDatabaseGateway? gateway, string instanceName, ILogger logger)
|
|
{
|
|
_gateway = gateway;
|
|
_instanceName = instanceName;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task<System.Data.Common.DbConnection> Connection(
|
|
string name,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_gateway == null)
|
|
throw new InvalidOperationException("Database gateway not available");
|
|
|
|
return await _gateway.GetConnectionAsync(name, cancellationToken);
|
|
}
|
|
|
|
public async Task CachedWrite(
|
|
string name,
|
|
string sql,
|
|
IReadOnlyDictionary<string, object?>? parameters = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_gateway == null)
|
|
throw new InvalidOperationException("Database gateway not available");
|
|
|
|
await _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notification Outbox: helper for the <c>Notify</c> script API.
|
|
///
|
|
/// In the outbox design the site no longer delivers notification email inline.
|
|
/// <c>Notify.To("listName").Send(...)</c> enqueues the notification into the site
|
|
/// Store-and-Forward Engine — which forwards it to central — and returns a
|
|
/// <c>NotificationId</c> handle immediately. <c>Notify.Status(id)</c> later queries
|
|
/// the delivery status of that notification.
|
|
/// </summary>
|
|
public class NotifyHelper
|
|
{
|
|
private readonly StoreAndForwardService? _storeAndForward;
|
|
private readonly ICanTell? _siteCommunicationActor;
|
|
private readonly string _siteId;
|
|
private readonly string _instanceName;
|
|
private readonly TimeSpan _askTimeout;
|
|
private readonly ILogger _logger;
|
|
|
|
internal NotifyHelper(
|
|
StoreAndForwardService? storeAndForward,
|
|
ICanTell? siteCommunicationActor,
|
|
string siteId,
|
|
string instanceName,
|
|
TimeSpan askTimeout,
|
|
ILogger logger)
|
|
{
|
|
_storeAndForward = storeAndForward;
|
|
_siteCommunicationActor = siteCommunicationActor;
|
|
_siteId = siteId;
|
|
_instanceName = instanceName;
|
|
_askTimeout = askTimeout;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Selects the notification list to send to.
|
|
/// </summary>
|
|
public NotifyTarget To(string listName)
|
|
{
|
|
return new NotifyTarget(
|
|
listName, _storeAndForward, _siteId, _instanceName, _logger);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queries the delivery status of a previously-sent notification.
|
|
///
|
|
/// The query is issued to central via the site communication actor. While the
|
|
/// notification is still buffered in the site Store-and-Forward Engine — central
|
|
/// has no row for it yet (<c>Found: false</c>) but the buffer still holds the id —
|
|
/// the status is reported as the site-local <c>Forwarding</c> state. If central
|
|
/// has a row, its status is mapped through verbatim. If central does not know the
|
|
/// id and it is not buffered locally, the status is <c>Unknown</c>.
|
|
/// </summary>
|
|
public async Task<NotificationDeliveryStatus> Status(string notificationId)
|
|
{
|
|
if (_siteCommunicationActor == null)
|
|
throw new InvalidOperationException(
|
|
"Notification status query is not available — site communication actor not wired");
|
|
|
|
var correlationId = Guid.NewGuid().ToString();
|
|
var query = new NotificationStatusQuery(correlationId, notificationId);
|
|
|
|
NotificationStatusResponse response;
|
|
try
|
|
{
|
|
response = await _siteCommunicationActor
|
|
.Ask<NotificationStatusResponse>(query, _askTimeout);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Central could not be reached. Fall through to the buffer check: if the
|
|
// notification is still in the local S&F buffer it is Forwarding.
|
|
_logger.LogWarning(ex,
|
|
"Notification status query for {NotificationId} did not reach central",
|
|
notificationId);
|
|
response = new NotificationStatusResponse(
|
|
correlationId, Found: false, Status: "Unknown",
|
|
RetryCount: 0, LastError: null, DeliveredAt: null);
|
|
}
|
|
|
|
if (response.Found)
|
|
{
|
|
return new NotificationDeliveryStatus(
|
|
response.Status, response.RetryCount, response.LastError, response.DeliveredAt);
|
|
}
|
|
|
|
// Central has no row. If the notification is still buffered at the site it
|
|
// is in transit — report the site-local Forwarding state. Otherwise it is
|
|
// genuinely unknown (never sent, or already forwarded and central lost it).
|
|
if (_storeAndForward != null)
|
|
{
|
|
var buffered = await _storeAndForward.GetMessageByIdAsync(notificationId);
|
|
if (buffered != null)
|
|
{
|
|
return new NotificationDeliveryStatus(
|
|
"Forwarding", buffered.RetryCount, buffered.LastError, DeliveredAt: null);
|
|
}
|
|
}
|
|
|
|
return new NotificationDeliveryStatus("Unknown", 0, null, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notification Outbox: target for <c>Notify.To("listName").Send(...)</c>.
|
|
/// </summary>
|
|
public class NotifyTarget
|
|
{
|
|
private readonly string _listName;
|
|
private readonly StoreAndForwardService? _storeAndForward;
|
|
private readonly string _siteId;
|
|
private readonly string _instanceName;
|
|
private readonly ILogger _logger;
|
|
|
|
internal NotifyTarget(
|
|
string listName,
|
|
StoreAndForwardService? storeAndForward,
|
|
string siteId,
|
|
string instanceName,
|
|
ILogger logger)
|
|
{
|
|
_listName = listName;
|
|
_storeAndForward = storeAndForward;
|
|
_siteId = siteId;
|
|
_instanceName = instanceName;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Enqueues a notification for central delivery and returns its
|
|
/// <c>NotificationId</c> immediately.
|
|
///
|
|
/// The notification is buffered into the site Store-and-Forward Engine under the
|
|
/// <see cref="StoreAndForwardCategory.Notification"/> category; the S&F
|
|
/// engine's <c>NotificationForwarder</c> forwards it to central and treats
|
|
/// central's ack as the delivery outcome. The returned <c>NotificationId</c> is
|
|
/// the single idempotency key end-to-end: it is the S&F message id, it is
|
|
/// carried inside the buffered payload, and it is the id the forwarder submits to
|
|
/// central. Pass it to <see cref="NotifyHelper.Status"/> to track delivery.
|
|
/// </summary>
|
|
public async Task<string> Send(
|
|
string subject,
|
|
string message,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (_storeAndForward == null)
|
|
throw new InvalidOperationException(
|
|
"Notification store-and-forward engine not available");
|
|
|
|
// The script controls the idempotency key: generate the NotificationId here,
|
|
// use it as the S&F message id, and carry it inside the buffered payload so
|
|
// the forwarder submits the same id to central on every retry.
|
|
var notificationId = Guid.NewGuid().ToString("N");
|
|
|
|
var payload = new NotificationSubmit(
|
|
NotificationId: notificationId,
|
|
ListName: _listName,
|
|
Subject: subject,
|
|
Body: message,
|
|
// SourceSiteId is re-stamped by the forwarder from its own site id; this
|
|
// value is the best-effort site id known to the script runtime.
|
|
SourceSiteId: _siteId,
|
|
SourceInstanceId: _instanceName,
|
|
// SourceScript: the script runtime does not currently thread the script
|
|
// name down to the Notify helper; left null until that wiring exists.
|
|
SourceScript: null,
|
|
SiteEnqueuedAt: DateTimeOffset.UtcNow);
|
|
|
|
var payloadJson = JsonSerializer.Serialize(payload);
|
|
|
|
// The S&F engine assigns its own GUID to the message; pin the message id to
|
|
// the NotificationId so the buffer can be queried by it (Notify.Status) and
|
|
// the forwarder's idempotency key matches the buffered row.
|
|
await _storeAndForward.EnqueueAsync(
|
|
StoreAndForwardCategory.Notification,
|
|
target: _listName,
|
|
payloadJson: payloadJson,
|
|
originInstanceName: _instanceName,
|
|
messageId: notificationId);
|
|
|
|
_logger.LogDebug(
|
|
"Notify enqueued notification {NotificationId} to list '{List}' for central delivery",
|
|
notificationId, _listName);
|
|
|
|
return notificationId;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notification Outbox: the delivery status of a notification, as returned to a
|
|
/// script by <c>Notify.Status(id)</c>.
|
|
///
|
|
/// <see cref="Status"/> is either a central status (<c>Pending</c>, <c>Retrying</c>,
|
|
/// <c>Delivered</c>, <c>Parked</c>, <c>Discarded</c>), the site-local <c>Forwarding</c>
|
|
/// state (the notification is still buffered at the site and has not yet been
|
|
/// forwarded/acked), or <c>Unknown</c> (no central row and not buffered locally).
|
|
/// </summary>
|
|
public record NotificationDeliveryStatus(
|
|
string Status,
|
|
int RetryCount,
|
|
string? LastError,
|
|
DateTimeOffset? DeliveredAt);
|