fix(audit): populate ParentExecutionId on alarm-triggered script runs
M5.4 T4 threaded a `parentExecutionId` parameter through
AlarmActor.SpawnAlarmExecution → AlarmExecutionActor → ScriptRuntimeContext,
but every call site passed null — so alarm on-trigger runs were silently always
execution-tree roots, contradicting the "tag-cascade coverage is complete"
claim in CLAUDE.md and Component-AuditLog.md.
Source the id where a spawner genuinely exists: a static attribute write issued
by a site script (`Instance.SetAttribute`) or by an inbound API request
(`Route.To(...).SetAttributes(...)`, whose ParentExecutionId was already carried
to the site and then dropped). The id rides site-locally through three additive,
nullable fields — no wire, proto or central schema change:
ScriptRuntimeContext.SetAttribute / RouteToSetAttributesRequest.ParentExecutionId
→ SetStaticAttributeCommand.SourceExecutionId
→ AttributeValueChanged.SourceExecutionId (InstanceActor static-write path)
→ AlarmActor.SpawnAlarmExecution → AlarmExecutionActor → ScriptRuntimeContext
All four computed trigger types participate. Expression triggers evaluate off
the dispatcher, so the writer of the newest value folded into the snapshot is
captured *with* the snapshot and echoed home on ExpressionEvalResult /
ExpressionEvalFailed — a change arriving mid-flight cannot mis-attribute the
raise.
Deliberately still roots (documented, not deferred): alarms fired by Data
Connection Layer values (external device data has no spawning execution — this
includes the device echo of a script write to a *data-sourced* attribute, so
only static writes cascade), and ScriptActor value-change/conditional/
expression/timer trigger runs (a timer tick has no spawner; a WhileTrue/interval
run has no single identifiable write).
Tests: new SiteRuntime.Tests/Actors/AlarmCascadeParentExecutionTests pins all
three hops — SetAttribute stamps the run's ExecutionId, InstanceActor publishes
it on the change (and publishes null when absent), and ValueMatch/HiLo/
Expression alarms parent the on-trigger run to the writer while a DCL-originated
change leaves it a root.
Docs: CLAUDE.md and Component-AuditLog.md corrected from "complete" to the true
behaviour; Component-SiteRuntime.md gains an "Audit correlation of an on-trigger
run" section with the hop table and the by-design root cases.
This commit is contained in:
@@ -149,7 +149,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
|||||||
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
|
- Scope = script trust boundary: outbound API (sync + cached), outbound DB (sync + cached), notifications, inbound API. Framework/internal traffic is explicitly excluded.
|
||||||
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
|
- One row per lifecycle event; cached calls produce 4+ rows per operation (`Submitted`, `Forwarded`, `Attempted`, `Delivered`/`Parked`/`Discarded`).
|
||||||
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
|
- `ExecutionId` (`uniqueidentifier NULL`) is the universal per-run correlation value — every audit row emitted by one script execution / inbound request shares it; `CorrelationId` remains the per-operation lifecycle id (NULL for sync one-shots).
|
||||||
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. Tag-cascade coverage is complete as of M5.4 (T4) — no further spawn points are deferred.
|
- `ParentExecutionId` (`uniqueidentifier NULL`) is the cross-execution spawn pointer — every row of a spawned run carries the spawner's `ExecutionId`; bridges inbound API → routed-site-script, alarm-triggered on-trigger scripts, and nested `CallScript`/`CallShared` invocations; `IX_AuditLog_ParentExecution` backs the filter + the recursive execution-tree walk. **Tag-cascade (alarm leg) is populated, not just plumbed** — M5.4 T4 threaded the `parentExecutionId` parameter but every `AlarmActor.SpawnAlarmExecution` call site passed null, so alarm runs were silently always roots; the id now rides site-locally as `SetStaticAttributeCommand.SourceExecutionId` → `AttributeValueChanged.SourceExecutionId` → `SpawnAlarmExecution` (all additive/nullable, no wire/proto/schema change; `Expression` triggers capture the writer *with* the evaluated snapshot since the eval completes off-dispatcher). Sources are `ScriptRuntimeContext.SetAttribute` (the run's own `ExecutionId`) and `Route.To(...).SetAttributes(...)` (the inbound request's). **Still roots by design, not omission:** alarms fired by DCL data (external values have no spawning execution — including the device echo of a script write to a *data-sourced* attribute, so only **static** writes cascade), and `ScriptActor` value-change/conditional/expression/timer trigger runs (a timer tick has no spawner; a `WhileTrue`/interval run has no single identifiable write).
|
||||||
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
|
- Site SQLite hot-path first, then gRPC telemetry to central; ingest is idempotent on `EventId`; periodic reconciliation pull as fallback when telemetry is lost.
|
||||||
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
|
- Cached operations: site emits a single additively-extended `CachedCallTelemetry` packet carrying both audit events and operational state; central writes `AuditLog` + `SiteCalls` in one transaction.
|
||||||
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. Inbound API: full verbatim capture up to `InboundMaxBytes` (default 1 MiB); request headers stored in `Extra.requestHeaders` (post-redaction); per-method `SkipBodyCapture` flag suppresses bodies while still recording headers + metadata; `AuditInboundCeilingHits` counter surfaced on health snapshot. (M5.3 T7)
|
- Payload cap 8 KB by default / 64 KB on error rows; auth headers redacted by default; SQL parameter values captured by default; per-target redaction opt-in. Inbound API: full verbatim capture up to `InboundMaxBytes` (default 1 MiB); request headers stored in `Extra.requestHeaders` (post-redaction); per-method `SkipBodyCapture` flag suppresses bodies while still recording headers + metadata; `AuditInboundCeilingHits` counter surfaced on health snapshot. (M5.3 T7)
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ row per lifecycle event across all channels.
|
|||||||
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
||||||
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
||||||
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
|
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
|
||||||
| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`); NULL for a top-level run (inbound, tag-change / timer-triggered, un-bridged). |
|
| `ParentExecutionId` | `uniqueidentifier` NULL | The `ExecutionId` of the execution that *spawned* this run — the cross-execution correlation pointer. Set on every row of an inbound-API-routed site script run (= the inbound request's `ExecutionId`), a nested `CallScript`/`CallShared` run (= the caller's), and an alarm on-trigger run fired by a script- or inbound-API-initiated static attribute write (= the writer's). NULL for a top-level run: the inbound request itself, timer- and value-change-triggered scripts, and alarms fired by DCL (external device) data. |
|
||||||
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
| `SourceSiteId` | `varchar(64)` NULL | NULL for central-originated events. |
|
||||||
| `SourceNode` | `varchar(64)` NULL | The cluster node on which the event was emitted — `node-a` / `node-b` for site rows (qualified by `SourceSiteId`), `central-a` / `central-b` for central-originated rows. Nullable so reconciled rows from a node that has since been retired don't block ingest. |
|
| `SourceNode` | `varchar(64)` NULL | The cluster node on which the event was emitted — `node-a` / `node-b` for site rows (qualified by `SourceSiteId`), `central-a` / `central-b` for central-originated rows. Nullable so reconciled rows from a node that has since been retired don't block ingest. |
|
||||||
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
| `SourceInstanceId` | `varchar(128)` NULL | Instance whose script initiated the action (when applicable). |
|
||||||
@@ -195,20 +195,41 @@ known spawn points:
|
|||||||
that calls `Route.Call`; the routed site script records the inbound request's
|
that calls `Route.Call`; the routed site script records the inbound request's
|
||||||
`ExecutionId` as its `ParentExecutionId`, while the inbound `InboundRequest` row
|
`ExecutionId` as its `ParentExecutionId`, while the inbound `InboundRequest` row
|
||||||
is top-level (`ParentExecutionId` NULL).
|
is top-level (`ParentExecutionId` NULL).
|
||||||
- **Alarm-triggered on-trigger script** — when an alarm fires and its on-trigger
|
- **Alarm-triggered on-trigger script** — when a write trips an alarm and its
|
||||||
script runs (via `AlarmActor → AlarmExecutionActor`), the alarm context's
|
on-trigger script runs (via `AlarmActor → AlarmExecutionActor`), the run
|
||||||
`ExecutionId` is carried as the run's `ParentExecutionId`. Currently the alarm
|
records the **writing execution's** `ExecutionId` as its `ParentExecutionId`.
|
||||||
subsystem has no Guid-typed firing id so on-trigger runs are roots (NULL) in
|
The write's originating execution rides site-locally from
|
||||||
practice, but the wiring is in place for a future alarm `ExecutionId`.
|
`ScriptRuntimeContext.SetAttribute` (or the inbound API's
|
||||||
|
`Route.To(...).SetAttributes(...)`, whose `ParentExecutionId` is reused)
|
||||||
|
→ `SetStaticAttributeCommand.SourceExecutionId` → the Instance Actor's
|
||||||
|
published `AttributeValueChanged.SourceExecutionId` → the Alarm Actor's
|
||||||
|
`SpawnAlarmExecution`. All four computed trigger types are covered; for an
|
||||||
|
`Expression` trigger the writer captured with the evaluated snapshot is used,
|
||||||
|
since the evaluation completes off the dispatcher after the firing change has
|
||||||
|
left scope.
|
||||||
- **Nested `CallScript` / `CallShared` invocations** — when a script calls
|
- **Nested `CallScript` / `CallShared` invocations** — when a script calls
|
||||||
`Instance.CallScript(...)` or a shared script via `CallShared`, the calling
|
`Instance.CallScript(...)` or a shared script via `CallShared`, the calling
|
||||||
execution's `ExecutionId` threads into the spawned run as its
|
execution's `ExecutionId` threads into the spawned run as its
|
||||||
`ParentExecutionId`, making deeply nested call chains visible as a tree.
|
`ParentExecutionId`, making deeply nested call chains visible as a tree.
|
||||||
|
|
||||||
Attribute-write-triggered cascades (one tag change triggering another script via a
|
**Runs that remain roots — by design, not by omission.** `ParentExecutionId` is
|
||||||
tag subscription) are also wired: trigger-driven runs carry `ParentExecutionId =
|
NULL where no spawning execution exists:
|
||||||
NULL` (top-level roots), and any nested `CallScript`/`CallShared` they perform
|
|
||||||
chains as above. The schema is unchanged — no further tag-cascade work is deferred.
|
- **Alarms fired by Data Connection Layer data.** A value that arrives from a
|
||||||
|
device subscription has no originating execution, so it carries no
|
||||||
|
`SourceExecutionId` and the on-trigger run it fires is a root. This also covers
|
||||||
|
the *confirmed* value of a script-initiated write to a **data-sourced**
|
||||||
|
attribute: that write goes to the device and the echo returns on the
|
||||||
|
subscription, long after the writing execution ended. Only **static**
|
||||||
|
attribute writes (in-memory + persisted override) cascade.
|
||||||
|
- **Script value-change / conditional / expression triggers and timer-driven
|
||||||
|
runs** (`ScriptActor`). A timer tick has no spawner at all, and a
|
||||||
|
`WhileTrue`/interval script fires repeatedly from a timer rather than from one
|
||||||
|
identifiable write, so these runs stay roots; any nested `CallScript` /
|
||||||
|
`CallShared` they perform chains normally beneath them.
|
||||||
|
|
||||||
|
The schema is unchanged throughout — the cascade is carried on site-local
|
||||||
|
message fields, not on the wire or in central tables.
|
||||||
|
|
||||||
**Execution-tree traversal bound.** `GetExecutionTreeAsync` first walks up
|
**Execution-tree traversal bound.** `GetExecutionTreeAsync` first walks up
|
||||||
`ParentExecutionId` to the chain root, then walks down via a recursive CTE. The
|
`ParentExecutionId` to the chain root, then walks down via a recursive CTE. The
|
||||||
|
|||||||
@@ -259,6 +259,41 @@ When the Instance Actor is stopped (due to disable, delete, or redeployment), Ak
|
|||||||
- **Can** call instance scripts via `Instance.CallScript()` — sends an ask message to the appropriate sibling Script Actor.
|
- **Can** call instance scripts via `Instance.CallScript()` — sends an ask message to the appropriate sibling Script Actor.
|
||||||
- Instance scripts **cannot** call alarm on-trigger scripts — the call direction is one-way.
|
- Instance scripts **cannot** call alarm on-trigger scripts — the call direction is one-way.
|
||||||
|
|
||||||
|
### Audit correlation of an on-trigger run (`ParentExecutionId` tag-cascade)
|
||||||
|
|
||||||
|
An alarm fired by a **script- or inbound-API-initiated static attribute write**
|
||||||
|
is a genuine execution spawn, and the on-trigger run records the writing
|
||||||
|
execution as its parent in the central Audit Log (#23). The originating id rides
|
||||||
|
site-locally through three additive, nullable fields — no wire, proto or schema
|
||||||
|
change:
|
||||||
|
|
||||||
|
| Hop | Field |
|
||||||
|
|---|---|
|
||||||
|
| `ScriptRuntimeContext.SetAttribute` (this run's `ExecutionId`) / `RouteToSetAttributesRequest.ParentExecutionId` (the inbound request's) | → `SetStaticAttributeCommand.SourceExecutionId` |
|
||||||
|
| Instance Actor static-write path (`HandleSetStaticAttributeCore`) | → `AttributeValueChanged.SourceExecutionId` |
|
||||||
|
| Alarm Actor trigger evaluation → `SpawnAlarmExecution` | → `AlarmExecutionActor` → `ScriptRuntimeContext.ParentExecutionId` |
|
||||||
|
|
||||||
|
All four computed trigger types participate. `Expression` triggers evaluate a
|
||||||
|
whole attribute snapshot **off the dispatcher**, so the firing change is no
|
||||||
|
longer in scope when the boolean returns: the writer of the newest value folded
|
||||||
|
into the snapshot is captured *with* the snapshot and echoed back on the
|
||||||
|
evaluation result, so a change arriving mid-flight cannot mis-attribute the
|
||||||
|
raise.
|
||||||
|
|
||||||
|
The on-trigger run is correctly a **tree root** (`ParentExecutionId` NULL) when
|
||||||
|
the firing value has no originating execution:
|
||||||
|
|
||||||
|
- Values from the **Data Connection Layer** — external device data has no
|
||||||
|
spawning execution. This includes the confirmed value of a write to a
|
||||||
|
**data-sourced** attribute: that write is forwarded to the device and the echo
|
||||||
|
arrives on the subscription long after the writing execution ended, so only
|
||||||
|
**static** attribute writes cascade.
|
||||||
|
- Deploy-time seeding and central Test Run / tooling writes.
|
||||||
|
|
||||||
|
`AttributeValueChanged.SourceExecutionId` is deliberately **not** projected onto
|
||||||
|
the gRPC debug/stream wire shapes — the cascade is resolved inside the site node
|
||||||
|
that owns the Instance Actor, and central reads the linkage from audit rows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Native Alarm Actor
|
## Native Alarm Actor
|
||||||
|
|||||||
@@ -4,12 +4,28 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
|||||||
/// Command to set a static attribute value on an Instance Actor.
|
/// Command to set a static attribute value on an Instance Actor.
|
||||||
/// Updates in-memory state and persists the override to SQLite.
|
/// Updates in-memory state and persists the override to SQLite.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="CorrelationId">Per-operation correlation id.</param>
|
||||||
|
/// <param name="InstanceUniqueName">Unique name of the target instance.</param>
|
||||||
|
/// <param name="AttributeName">Canonical name of the attribute to write.</param>
|
||||||
|
/// <param name="Value">Canonical string form of the new value.</param>
|
||||||
|
/// <param name="Timestamp">UTC timestamp of the command.</param>
|
||||||
|
/// <param name="SourceExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
|
||||||
|
/// execution issuing this write — a site script run
|
||||||
|
/// (<c>ScriptRuntimeContext.SetAttribute</c>) or the inbound API request behind a
|
||||||
|
/// <c>Route.To(...).SetAttributes(...)</c>. Additive and nullable; <c>null</c>
|
||||||
|
/// when the write has no audited originating execution (central Test Run,
|
||||||
|
/// deployment tooling, tests). The Instance Actor stamps it onto the resulting
|
||||||
|
/// <c>AttributeValueChanged</c> so an alarm the write trips can record it as the
|
||||||
|
/// on-trigger script run's <c>ParentExecutionId</c>.
|
||||||
|
/// </param>
|
||||||
public record SetStaticAttributeCommand(
|
public record SetStaticAttributeCommand(
|
||||||
string CorrelationId,
|
string CorrelationId,
|
||||||
string InstanceUniqueName,
|
string InstanceUniqueName,
|
||||||
string AttributeName,
|
string AttributeName,
|
||||||
string Value,
|
string Value,
|
||||||
DateTimeOffset Timestamp);
|
DateTimeOffset Timestamp,
|
||||||
|
Guid? SourceExecutionId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Response confirming that a static attribute was set.
|
/// Response confirming that a static attribute was set.
|
||||||
|
|||||||
@@ -1,9 +1,43 @@
|
|||||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single attribute value change on a deployed instance — published to the
|
||||||
|
/// site-wide stream and routed to the interested child actors (Script Actors,
|
||||||
|
/// Alarm Actors) of the owning Instance Actor.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="InstanceUniqueName">Unique name of the instance the attribute belongs to.</param>
|
||||||
|
/// <param name="AttributePath">Path-qualified canonical name of the attribute.</param>
|
||||||
|
/// <param name="AttributeName">Canonical name of the attribute.</param>
|
||||||
|
/// <param name="Value">The new value.</param>
|
||||||
|
/// <param name="Quality">Quality of the new value (<c>Good</c>/<c>Bad</c>/<c>Uncertain</c>).</param>
|
||||||
|
/// <param name="Timestamp">UTC timestamp of the change.</param>
|
||||||
|
/// <param name="SourceExecutionId">
|
||||||
|
/// Audit Log #23 (ParentExecutionId tag-cascade): the <c>ExecutionId</c> of the
|
||||||
|
/// script execution / inbound API request whose write produced this change, or
|
||||||
|
/// <c>null</c> when the change did not originate from an audited execution —
|
||||||
|
/// which is the case for every value that arrives from the Data Connection
|
||||||
|
/// Layer (external device data), for deployment-time seeding, and for the
|
||||||
|
/// confirmed value of a data-sourced write (the device echo arrives on the
|
||||||
|
/// subscription long after the writing execution ended).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The Alarm Actor records this id as the <c>ParentExecutionId</c> of the
|
||||||
|
/// on-trigger script run the change fires, so a script-initiated write that
|
||||||
|
/// trips an alarm chains under the writing execution in the audit tree.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Deliberately site-local: the field is NOT projected onto the gRPC
|
||||||
|
/// <c>DebugAttributeValueDto</c> / <c>SiteStreamEvent</c> wire shapes — the
|
||||||
|
/// cascade is resolved entirely inside the site node that owns the Instance
|
||||||
|
/// Actor, and central reads the linkage from the audit rows instead.
|
||||||
|
/// </para>
|
||||||
|
/// </param>
|
||||||
public record AttributeValueChanged(
|
public record AttributeValueChanged(
|
||||||
string InstanceUniqueName,
|
string InstanceUniqueName,
|
||||||
string AttributePath,
|
string AttributePath,
|
||||||
string AttributeName,
|
string AttributeName,
|
||||||
object? Value,
|
object? Value,
|
||||||
string Quality,
|
string Quality,
|
||||||
DateTimeOffset Timestamp) : ISiteStreamEvent;
|
DateTimeOffset Timestamp,
|
||||||
|
Guid? SourceExecutionId = null) : ISiteStreamEvent;
|
||||||
|
|||||||
@@ -102,6 +102,18 @@ public class AlarmActor : ReceiveActor
|
|||||||
private bool _evalInFlight;
|
private bool _evalInFlight;
|
||||||
private bool _evalPending;
|
private bool _evalPending;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId tag-cascade): the
|
||||||
|
/// <see cref="AttributeValueChanged.SourceExecutionId"/> of the most recent
|
||||||
|
/// change folded into <see cref="_attributeSnapshot"/>. Expression triggers
|
||||||
|
/// evaluate a whole snapshot off the dispatcher, so there is no single
|
||||||
|
/// "triggering change" in scope when the boolean result comes back — this
|
||||||
|
/// field is the latest writer, captured into the in-flight evaluation by
|
||||||
|
/// <see cref="StartExpressionEvaluation"/> and echoed home on
|
||||||
|
/// <see cref="ExpressionEvalResult"/>. Touched only on the actor thread.
|
||||||
|
/// </summary>
|
||||||
|
private Guid? _latestSourceExecutionId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The exact dictionary instance this actor was seeded from
|
/// The exact dictionary instance this actor was seeded from
|
||||||
/// at construction. The Instance Actor must pass a private snapshot here, not
|
/// at construction. The Instance Actor must pass a private snapshot here, not
|
||||||
@@ -110,6 +122,18 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal IReadOnlyDictionary<string, object?>? SeedAttributesReference { get; }
|
internal IReadOnlyDictionary<string, object?>? SeedAttributesReference { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (ParentExecutionId tag-cascade): the
|
||||||
|
/// <c>parentExecutionId</c> handed to the most recently spawned
|
||||||
|
/// <see cref="AlarmExecutionActor"/> — i.e. the execution whose attribute
|
||||||
|
/// write fired this alarm, or <c>null</c> when the firing change came from
|
||||||
|
/// the Data Connection Layer (external data has no spawning execution).
|
||||||
|
/// The spawned actor builds its own <see cref="ScriptRuntimeContext"/>
|
||||||
|
/// internally, so this is exposed for regression coverage of the cascade
|
||||||
|
/// contract (mirrors <see cref="SeedAttributesReference"/>).
|
||||||
|
/// </summary>
|
||||||
|
internal Guid? LastOnTriggerParentExecutionId { get; private set; }
|
||||||
|
|
||||||
// Rate of change tracking
|
// Rate of change tracking
|
||||||
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
|
private readonly Queue<(DateTimeOffset Timestamp, double Value)> _rateOfChangeWindow = new();
|
||||||
private readonly TimeSpan _rateOfChangeWindowDuration;
|
private readonly TimeSpan _rateOfChangeWindowDuration;
|
||||||
@@ -241,6 +265,10 @@ public class AlarmActor : ReceiveActor
|
|||||||
if (_triggerType == AlarmTriggerType.Expression)
|
if (_triggerType == AlarmTriggerType.Expression)
|
||||||
{
|
{
|
||||||
_attributeSnapshot[changed.AttributeName] = changed.Value;
|
_attributeSnapshot[changed.AttributeName] = changed.Value;
|
||||||
|
// (ParentExecutionId tag-cascade): remember which
|
||||||
|
// execution (if any) wrote the newest value folded into the snapshot,
|
||||||
|
// so the off-dispatcher evaluation can attribute its raise to it.
|
||||||
|
_latestSourceExecutionId = changed.SourceExecutionId;
|
||||||
}
|
}
|
||||||
else if (!IsMonitoredAttribute(changed.AttributeName))
|
else if (!IsMonitoredAttribute(changed.AttributeName))
|
||||||
{
|
{
|
||||||
@@ -252,7 +280,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
{
|
{
|
||||||
if (_triggerType == AlarmTriggerType.HiLo)
|
if (_triggerType == AlarmTriggerType.HiLo)
|
||||||
{
|
{
|
||||||
HandleHiLoTransition(EvaluateHiLo(changed.Value));
|
HandleHiLoTransition(EvaluateHiLo(changed.Value), changed.SourceExecutionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +302,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
ApplyTriggeredState(isTriggered);
|
ApplyTriggeredState(isTriggered, changed.SourceExecutionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -292,7 +320,14 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// Expression path (via <see cref="HandleExpressionEvalResult"/>). Edge state
|
/// Expression path (via <see cref="HandleExpressionEvalResult"/>). Edge state
|
||||||
/// (<see cref="_currentState"/>) is touched only on the actor thread.
|
/// (<see cref="_currentState"/>) is touched only on the actor thread.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ApplyTriggeredState(bool isTriggered)
|
/// <param name="isTriggered">The evaluated truth value of the alarm condition.</param>
|
||||||
|
/// <param name="sourceExecutionId">
|
||||||
|
/// (ParentExecutionId tag-cascade): the execution that
|
||||||
|
/// wrote the value which produced this evaluation, recorded as the
|
||||||
|
/// on-trigger script run's <c>ParentExecutionId</c>. Null when the value
|
||||||
|
/// came from the DCL (external data has no spawning execution).
|
||||||
|
/// </param>
|
||||||
|
private void ApplyTriggeredState(bool isTriggered, Guid? sourceExecutionId = null)
|
||||||
{
|
{
|
||||||
if (isTriggered && _currentState == AlarmState.Normal)
|
if (isTriggered && _currentState == AlarmState.Normal)
|
||||||
{
|
{
|
||||||
@@ -313,7 +348,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
// Spawn AlarmExecutionActor if on-trigger script defined
|
// Spawn AlarmExecutionActor if on-trigger script defined
|
||||||
if (_onTriggerCompiledScript != null)
|
if (_onTriggerCompiledScript != null)
|
||||||
{
|
{
|
||||||
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty);
|
SpawnAlarmExecution(AlarmLevel.None, _priority, string.Empty, sourceExecutionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (!isTriggered && _currentState == AlarmState.Active)
|
else if (!isTriggered && _currentState == AlarmState.Active)
|
||||||
@@ -339,7 +374,13 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// edge (i.e., when entering an alarm band from the normal band) — not on
|
/// edge (i.e., when entering an alarm band from the normal band) — not on
|
||||||
/// level escalations like Hi→HiHi or Low→LowLow.
|
/// level escalations like Hi→HiHi or Low→LowLow.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void HandleHiLoTransition(AlarmLevel newLevel)
|
/// <param name="newLevel">The newly evaluated HiLo band.</param>
|
||||||
|
/// <param name="sourceExecutionId">
|
||||||
|
/// (ParentExecutionId tag-cascade): the execution that
|
||||||
|
/// wrote the value driving this transition; recorded as the on-trigger
|
||||||
|
/// script run's <c>ParentExecutionId</c>. Null for DCL-originated values.
|
||||||
|
/// </param>
|
||||||
|
private void HandleHiLoTransition(AlarmLevel newLevel, Guid? sourceExecutionId = null)
|
||||||
{
|
{
|
||||||
if (newLevel == _currentLevel) return;
|
if (newLevel == _currentLevel) return;
|
||||||
|
|
||||||
@@ -383,7 +424,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
&& newLevel != AlarmLevel.None
|
&& newLevel != AlarmLevel.None
|
||||||
&& _onTriggerCompiledScript != null)
|
&& _onTriggerCompiledScript != null)
|
||||||
{
|
{
|
||||||
SpawnAlarmExecution(newLevel, priority, message);
|
SpawnAlarmExecution(newLevel, priority, message, sourceExecutionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,6 +584,11 @@ public class AlarmActor : ReceiveActor
|
|||||||
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
|
var snapshot = new Dictionary<string, object?>(_attributeSnapshot); // point-in-time copy, actor thread
|
||||||
var expression = _compiledTriggerExpression;
|
var expression = _compiledTriggerExpression;
|
||||||
var self = Self;
|
var self = Self;
|
||||||
|
// (ParentExecutionId tag-cascade): capture the writer of
|
||||||
|
// the newest value in THIS snapshot alongside the snapshot itself, so a
|
||||||
|
// later change arriving while the evaluation is in flight cannot
|
||||||
|
// mis-attribute the raise this evaluation produces.
|
||||||
|
var sourceExecutionId = _latestSourceExecutionId;
|
||||||
Task.Factory.StartNew(async () =>
|
Task.Factory.StartNew(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -568,8 +614,8 @@ public class AlarmActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
|
}, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
|
||||||
_scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
|
_scheduler ?? ScriptExecutionScheduler.Shared(_options)).Unwrap().PipeTo(self,
|
||||||
success: r => new ExpressionEvalResult(r),
|
success: r => new ExpressionEvalResult(r, sourceExecutionId),
|
||||||
failure: ex => new ExpressionEvalFailed(ex));
|
failure: ex => new ExpressionEvalFailed(ex, sourceExecutionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -582,7 +628,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
_evalInFlight = false;
|
_evalInFlight = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ApplyTriggeredState(msg.Result);
|
ApplyTriggeredState(msg.Result, msg.SourceExecutionId);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -607,7 +653,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
_logger.LogError(msg.Cause,
|
_logger.LogError(msg.Cause,
|
||||||
"Alarm {Alarm} trigger-expression evaluation task faulted on {Instance}; treated as false",
|
"Alarm {Alarm} trigger-expression evaluation task faulted on {Instance}; treated as false",
|
||||||
_alarmName, _instanceName);
|
_alarmName, _instanceName);
|
||||||
HandleExpressionEvalResult(new ExpressionEvalResult(false));
|
HandleExpressionEvalResult(new ExpressionEvalResult(false, msg.SourceExecutionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -667,13 +713,14 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// <param name="priority">The firing alarm priority.</param>
|
/// <param name="priority">The firing alarm priority.</param>
|
||||||
/// <param name="message">The firing alarm message.</param>
|
/// <param name="message">The firing alarm message.</param>
|
||||||
/// <param name="parentExecutionId">
|
/// <param name="parentExecutionId">
|
||||||
/// The execution id of
|
/// (ParentExecutionId tag-cascade): the <c>ExecutionId</c>
|
||||||
/// the context that fired this alarm, recorded as the on-trigger script run's
|
/// of the execution whose attribute write fired this alarm — a site script
|
||||||
/// <c>ParentExecutionId</c> so the alarm-triggered run chains under its firing
|
/// run (<c>Instance.SetAttribute</c>) or the inbound API request behind a
|
||||||
/// context in the audit tree. The alarm subsystem currently has no Guid-typed
|
/// <c>Route.To(...).SetAttributes(...)</c> — recorded as the on-trigger
|
||||||
/// firing id, so the only call sites pass <c>null</c> (the on-trigger run is a
|
/// script run's <c>ParentExecutionId</c> so the alarm-triggered run chains
|
||||||
/// root). The parameter exists so a future firing-id can flow without
|
/// under its firing execution in the audit tree. <c>null</c> when the firing
|
||||||
/// touching the actor wiring.
|
/// value arrived from the Data Connection Layer: external device data has no
|
||||||
|
/// spawning execution, so that on-trigger run is correctly a tree ROOT.
|
||||||
/// </param>
|
/// </param>
|
||||||
private void SpawnAlarmExecution(
|
private void SpawnAlarmExecution(
|
||||||
AlarmLevel level, int priority, string message, Guid? parentExecutionId = null)
|
AlarmLevel level, int priority, string message, Guid? parentExecutionId = null)
|
||||||
@@ -682,6 +729,10 @@ public class AlarmActor : ReceiveActor
|
|||||||
|
|
||||||
var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}";
|
var executionId = $"{_alarmName}-alarm-exec-{_executionCounter++}";
|
||||||
|
|
||||||
|
// Record what the on-trigger run was parented to (null = root); read by
|
||||||
|
// the tag-cascade regression tests, which cannot see inside the child.
|
||||||
|
LastOnTriggerParentExecutionId = parentExecutionId;
|
||||||
|
|
||||||
// The on-trigger script body runs on the dedicated
|
// The on-trigger script body runs on the dedicated
|
||||||
// ScriptExecutionScheduler, not the shared .NET thread pool.
|
// ScriptExecutionScheduler, not the shared .NET thread pool.
|
||||||
var props = Props.Create(() => new AlarmExecutionActor(
|
var props = Props.Create(() => new AlarmExecutionActor(
|
||||||
@@ -697,7 +748,7 @@ public class AlarmActor : ReceiveActor
|
|||||||
_logger,
|
_logger,
|
||||||
// Per-script timeout from the on-trigger script (null = global).
|
// Per-script timeout from the on-trigger script (null = global).
|
||||||
_onTriggerExecutionTimeoutSeconds,
|
_onTriggerExecutionTimeoutSeconds,
|
||||||
// The firing context's execution id (null today).
|
// The firing execution's id — null for DCL-originated changes.
|
||||||
parentExecutionId,
|
parentExecutionId,
|
||||||
// Scheduler seam (#18): share this alarm's scheduler override with the
|
// Scheduler seam (#18): share this alarm's scheduler override with the
|
||||||
// spawned on-trigger script body (null = process-wide shared).
|
// spawned on-trigger script body (null = process-wide shared).
|
||||||
@@ -829,9 +880,12 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Piped back to self from an off-dispatcher trigger-expression evaluation (P1);
|
/// Piped back to self from an off-dispatcher trigger-expression evaluation (P1);
|
||||||
/// carries the boolean truth value so the Normal↔Active transition runs on the
|
/// carries the boolean truth value so the Normal↔Active transition runs on the
|
||||||
/// actor thread.
|
/// actor thread, plus the <c>SourceExecutionId</c> captured with the evaluated
|
||||||
|
/// snapshot (ParentExecutionId tag-cascade) so a raise is attributed
|
||||||
|
/// to the execution that actually wrote the value it evaluated — not to a
|
||||||
|
/// later change that arrived while the evaluation was in flight.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed record ExpressionEvalResult(bool Result);
|
private sealed record ExpressionEvalResult(bool Result, Guid? SourceExecutionId = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Piped back to self when the off-dispatcher evaluation TASK itself faults
|
/// Piped back to self when the off-dispatcher evaluation TASK itself faults
|
||||||
@@ -839,8 +893,10 @@ public class AlarmActor : ReceiveActor
|
|||||||
/// during shutdown) — the inner body's catch never sees that. Without this
|
/// during shutdown) — the inner body's catch never sees that. Without this
|
||||||
/// mapping the actor receives an unhandled Status.Failure and _evalInFlight
|
/// mapping the actor receives an unhandled Status.Failure and _evalInFlight
|
||||||
/// stays true forever, permanently parking the expression trigger (N2).
|
/// stays true forever, permanently parking the expression trigger (N2).
|
||||||
|
/// The captured <c>SourceExecutionId</c> rides along so the synthesized
|
||||||
|
/// false result keeps the same attribution the successful path would have had.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record ExpressionEvalFailed(Exception Cause);
|
internal sealed record ExpressionEvalFailed(Exception Cause, Guid? SourceExecutionId = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal enum RateOfChangeDirection { Either, Rising, Falling }
|
internal enum RateOfChangeDirection { Either, Rising, Falling }
|
||||||
|
|||||||
@@ -30,12 +30,13 @@ public class AlarmExecutionActor : ReceiveActor
|
|||||||
/// <param name="logger">Logger for execution diagnostics.</param>
|
/// <param name="logger">Logger for execution diagnostics.</param>
|
||||||
/// <param name="executionTimeoutSeconds">The on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
|
/// <param name="executionTimeoutSeconds">The on-trigger script's per-script execution timeout in seconds. Null or non-positive falls back to the global <see cref="SiteRuntimeOptions.ScriptExecutionTimeoutSeconds"/>.</param>
|
||||||
/// <param name="parentExecutionId">
|
/// <param name="parentExecutionId">
|
||||||
/// ParentExecutionId tag-cascade: the execution id of
|
/// ParentExecutionId tag-cascade: the <c>ExecutionId</c> of
|
||||||
/// the context that fired this alarm, threaded into the on-trigger script's
|
/// the execution whose attribute write fired this alarm, threaded into the
|
||||||
/// <see cref="ScriptRuntimeContext"/> as its <c>ParentExecutionId</c> so the
|
/// on-trigger script's <see cref="ScriptRuntimeContext"/> as its
|
||||||
/// alarm-triggered run chains under its firing context. Null today (no
|
/// <c>ParentExecutionId</c> so the alarm-triggered run chains under its
|
||||||
/// Guid-typed firing id exists yet) — the run is a root, but the plumbing
|
/// firing execution. Null when the firing value came from the Data
|
||||||
/// is in place for a future firing id.
|
/// Connection Layer (external data has no spawning execution) — that
|
||||||
|
/// on-trigger run is a tree root.
|
||||||
/// </param>
|
/// </param>
|
||||||
public AlarmExecutionActor(
|
public AlarmExecutionActor(
|
||||||
string alarmName,
|
string alarmName,
|
||||||
@@ -115,9 +116,8 @@ public class AlarmExecutionActor : ReceiveActor
|
|||||||
// ParentExecutionId tag-cascade: the
|
// ParentExecutionId tag-cascade: the
|
||||||
// alarm on-trigger run mints its own fresh ExecutionId (the
|
// alarm on-trigger run mints its own fresh ExecutionId (the
|
||||||
// ctor's `?? NewGuid()` fallback) and records the firing
|
// ctor's `?? NewGuid()` fallback) and records the firing
|
||||||
// context's id as its ParentExecutionId — null today, so the
|
// execution's id as its ParentExecutionId — null (a root)
|
||||||
// run is a root, but the plumbing exists for a future
|
// only when the firing value came from the DCL.
|
||||||
// firing id.
|
|
||||||
parentExecutionId: parentExecutionId,
|
parentExecutionId: parentExecutionId,
|
||||||
// WaitForAttribute (spec §4.4): thread the alarm on-trigger
|
// WaitForAttribute (spec §4.4): thread the alarm on-trigger
|
||||||
// script's per-script execution-timeout token so a
|
// script's per-script execution-timeout token so a
|
||||||
|
|||||||
@@ -1826,7 +1826,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
var asks = request.AttributeValues
|
var asks = request.AttributeValues
|
||||||
.Select(kvp => instanceActor.Ask<SetStaticAttributeResponse>(
|
.Select(kvp => instanceActor.Ask<SetStaticAttributeResponse>(
|
||||||
new SetStaticAttributeCommand(
|
new SetStaticAttributeCommand(
|
||||||
correlationId, request.InstanceUniqueName, kvp.Key, kvp.Value, DateTimeOffset.UtcNow),
|
correlationId, request.InstanceUniqueName, kvp.Key, kvp.Value, DateTimeOffset.UtcNow,
|
||||||
|
// (ParentExecutionId tag-cascade): the routed
|
||||||
|
// write's originating execution is the inbound API request
|
||||||
|
// that issued Route.To(...).SetAttributes(...) — already
|
||||||
|
// carried on the request as ParentExecutionId. Stamping it
|
||||||
|
// here lets an alarm this write trips chain its on-trigger
|
||||||
|
// script run under the inbound execution.
|
||||||
|
SourceExecutionId: request.ParentExecutionId),
|
||||||
TimeSpan.FromSeconds(30)))
|
TimeSpan.FromSeconds(30)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
|||||||
@@ -462,7 +462,12 @@ public class InstanceActor : ReceiveActor
|
|||||||
command.AttributeName,
|
command.AttributeName,
|
||||||
command.Value,
|
command.Value,
|
||||||
"Good",
|
"Good",
|
||||||
DateTimeOffset.UtcNow);
|
DateTimeOffset.UtcNow,
|
||||||
|
// (ParentExecutionId tag-cascade): carry the writing
|
||||||
|
// execution's id onto the change so a child Alarm Actor this write
|
||||||
|
// trips can chain its on-trigger script run under the writer. Null for
|
||||||
|
// writes with no audited origin (central Test Run, deployment tooling).
|
||||||
|
SourceExecutionId: command.SourceExecutionId);
|
||||||
|
|
||||||
PublishAndNotifyChildren(changed);
|
PublishAndNotifyChildren(changed);
|
||||||
|
|
||||||
|
|||||||
@@ -151,10 +151,13 @@ public class ScriptRuntimeContext
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// (ParentExecutionId): the spawning execution's
|
/// (ParentExecutionId): the spawning execution's
|
||||||
/// <see cref="_executionId"/> when this script run was spawned by another
|
/// <see cref="_executionId"/> when this script run was spawned by another
|
||||||
/// execution — for an inbound-API-routed call this is the inbound request's
|
/// execution — the inbound request's per-request execution id for an
|
||||||
/// per-request execution id. <c>null</c> for normal (tag-change /
|
/// inbound-API-routed call, the caller's id for a nested
|
||||||
/// timer-triggered) runs and nested <c>CallScript</c> invocations. The
|
/// <c>CallScript</c>/<c>CallShared</c>, and the writing execution's id for an
|
||||||
/// routed script still mints its OWN fresh <see cref="_executionId"/>; this
|
/// alarm on-trigger run fired by a script- or inbound-API-initiated attribute
|
||||||
|
/// write. <c>null</c> for genuinely top-level runs: timer-triggered scripts,
|
||||||
|
/// script value-change triggers, and alarms fired by DCL (external) data. The
|
||||||
|
/// spawned script still mints its OWN fresh <see cref="_executionId"/>; this
|
||||||
/// field records the spawner so a spawned execution's audit rows can point
|
/// field records the spawner so a spawned execution's audit rows can point
|
||||||
/// back at the execution that spawned it.
|
/// back at the execution that spawned it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -514,7 +517,12 @@ public class ScriptRuntimeContext
|
|||||||
{
|
{
|
||||||
var correlationId = Guid.NewGuid().ToString();
|
var correlationId = Guid.NewGuid().ToString();
|
||||||
var command = new SetStaticAttributeCommand(
|
var command = new SetStaticAttributeCommand(
|
||||||
correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow);
|
correlationId, _instanceName, attributeName, value, DateTimeOffset.UtcNow,
|
||||||
|
// (ParentExecutionId tag-cascade): stamp THIS run's own
|
||||||
|
// ExecutionId as the write's originating execution. The Instance Actor
|
||||||
|
// carries it onto the published AttributeValueChanged, so an alarm this
|
||||||
|
// write trips records this run as the on-trigger script's parent.
|
||||||
|
SourceExecutionId: _executionId);
|
||||||
|
|
||||||
// Ask — mutation serialized through the Instance Actor mailbox; the reply
|
// Ask — mutation serialized through the Instance Actor mailbox; the reply
|
||||||
// carries the device-write outcome for data-connected attributes.
|
// carries the device-write outcome for data-connected attributes.
|
||||||
|
|||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Audit Log #23 (M5.4 T4 — <c>ParentExecutionId</c> tag-cascade, alarm leg):
|
||||||
|
/// an alarm on-trigger script run must chain under the execution whose attribute
|
||||||
|
/// write fired the alarm, and must stay a tree ROOT when the firing value had no
|
||||||
|
/// audited origin.
|
||||||
|
///
|
||||||
|
/// <para>The cascade is three hops, each pinned here:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description>
|
||||||
|
/// <c>ScriptRuntimeContext.SetAttribute</c> stamps the running script's
|
||||||
|
/// <c>ExecutionId</c> onto <see cref="SetStaticAttributeCommand.SourceExecutionId"/>.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// The Instance Actor carries that id onto the published
|
||||||
|
/// <see cref="AttributeValueChanged.SourceExecutionId"/>.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// The Alarm Actor hands it to the spawned <c>AlarmExecutionActor</c> as the
|
||||||
|
/// on-trigger run's <c>ParentExecutionId</c> — observable via
|
||||||
|
/// <c>AlarmActor.LastOnTriggerParentExecutionId</c>.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// A value arriving from the Data Connection Layer (external device data) carries
|
||||||
|
/// no <c>SourceExecutionId</c>, so the alarm it fires is correctly parentless.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public class AlarmCascadeParentExecutionTests : TestKit, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ScriptCompilationService _compilationService;
|
||||||
|
private readonly SharedScriptLibrary _sharedLibrary;
|
||||||
|
private readonly SiteRuntimeOptions _options;
|
||||||
|
private readonly TestLocalDb _localDb;
|
||||||
|
private readonly SiteStorageService _storage;
|
||||||
|
|
||||||
|
public AlarmCascadeParentExecutionTests()
|
||||||
|
{
|
||||||
|
_compilationService = new ScriptCompilationService(
|
||||||
|
NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
_sharedLibrary = new SharedScriptLibrary(
|
||||||
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
_options = new SiteRuntimeOptions();
|
||||||
|
_localDb = TestLocalDb.CreateTemp("alarm-cascade-test");
|
||||||
|
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
||||||
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
void IDisposable.Dispose()
|
||||||
|
{
|
||||||
|
// TestKit teardown first, so no in-flight actor can reach a disposed ILocalDb;
|
||||||
|
// then dispose the database before deleting — the master connection anchors the WAL.
|
||||||
|
Shutdown();
|
||||||
|
var path = _localDb.Path;
|
||||||
|
_localDb.Dispose();
|
||||||
|
TestLocalDb.DeleteFiles(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A trivially-succeeding on-trigger script, so the alarm actually spawns an execution.</summary>
|
||||||
|
private Script<object?> OnTriggerScript()
|
||||||
|
{
|
||||||
|
var compiled = _compilationService.Compile("OnTrigger", "return null;");
|
||||||
|
Assert.NotNull(compiled.CompiledScript);
|
||||||
|
return compiled.CompiledScript!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles a trigger expression outside the trust validator (mirrors
|
||||||
|
/// <c>AlarmActorTests.CompileRawTriggerExpression</c>).
|
||||||
|
/// </summary>
|
||||||
|
private static Script<object?> CompileTriggerExpression(string expression)
|
||||||
|
{
|
||||||
|
var opts = ScriptOptions.Default
|
||||||
|
.WithReferences(typeof(object).Assembly, typeof(Enumerable).Assembly)
|
||||||
|
.WithImports("System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks");
|
||||||
|
var script = CSharpScript.Create<object?>(expression, opts, typeof(TriggerExpressionGlobals));
|
||||||
|
script.Compile();
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an Alarm Actor as a <c>TestActorRef</c> so the spawn-time
|
||||||
|
/// <c>ParentExecutionId</c> can be read off the underlying actor (the spawned
|
||||||
|
/// child builds its <c>ScriptRuntimeContext</c> internally and exposes nothing).
|
||||||
|
/// </summary>
|
||||||
|
private TestActorRef<AlarmActor> BuildAlarm(
|
||||||
|
ResolvedAlarm config, out Akka.TestKit.TestProbe instanceProbe,
|
||||||
|
Script<object?>? triggerExpression = null)
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
instanceProbe = probe;
|
||||||
|
return ActorOfAsTestActorRef<AlarmActor>(
|
||||||
|
Props.Create(() => new AlarmActor(
|
||||||
|
config.CanonicalName, "Pump1", probe.Ref, config,
|
||||||
|
OnTriggerScript(), _sharedLibrary, _options,
|
||||||
|
NullLogger<AlarmActor>.Instance, triggerExpression)),
|
||||||
|
"alarm-" + Guid.NewGuid().ToString("N"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResolvedAlarm ValueMatchAlarm() => new()
|
||||||
|
{
|
||||||
|
CanonicalName = "HighTemp",
|
||||||
|
TriggerType = "ValueMatch",
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Status\",\"matchValue\":\"Critical\"}",
|
||||||
|
PriorityLevel = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hop 3 — Alarm Actor → AlarmExecutionActor
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptWrittenValue_FiringAlarm_ParentsOnTriggerRunToTheWritingExecution()
|
||||||
|
{
|
||||||
|
var writerExecutionId = Guid.NewGuid();
|
||||||
|
var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe);
|
||||||
|
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow,
|
||||||
|
SourceExecutionId: writerExecutionId));
|
||||||
|
|
||||||
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DclOriginatedValue_FiringAlarm_LeavesOnTriggerRunAsATreeRoot()
|
||||||
|
{
|
||||||
|
// External device data has no spawning execution: null is the CORRECT
|
||||||
|
// answer here, not a gap. The on-trigger run is a tree root.
|
||||||
|
var alarm = BuildAlarm(ValueMatchAlarm(), out var instanceProbe);
|
||||||
|
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "Status", "Status", "Critical", "Good", DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
// The alarm really did raise (and therefore really did spawn the
|
||||||
|
// on-trigger run) — so the null below is a recorded root, not a no-op.
|
||||||
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Null(alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HiLoAlarm_EnteringABand_ParentsOnTriggerRunToTheWritingExecution()
|
||||||
|
{
|
||||||
|
var writerExecutionId = Guid.NewGuid();
|
||||||
|
var alarm = BuildAlarm(
|
||||||
|
new ResolvedAlarm
|
||||||
|
{
|
||||||
|
CanonicalName = "TempBand",
|
||||||
|
TriggerType = "HiLo",
|
||||||
|
TriggerConfiguration = "{\"attributeName\":\"Temp\",\"hi\":80,\"hiHi\":95}",
|
||||||
|
PriorityLevel = 1
|
||||||
|
},
|
||||||
|
out var instanceProbe);
|
||||||
|
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "Temp", "Temp", 90.0, "Good", DateTimeOffset.UtcNow,
|
||||||
|
SourceExecutionId: writerExecutionId));
|
||||||
|
|
||||||
|
instanceProbe.ExpectMsg<AlarmStateChanged>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ExpressionAlarm_CarriesTheWriterCapturedWithTheEvaluatedSnapshot()
|
||||||
|
{
|
||||||
|
// Expression triggers evaluate a whole snapshot OFF the dispatcher, so the
|
||||||
|
// firing change is no longer in scope when the boolean comes back. The
|
||||||
|
// writer is captured alongside the snapshot and echoed home on the result.
|
||||||
|
var writerExecutionId = Guid.NewGuid();
|
||||||
|
var alarm = BuildAlarm(
|
||||||
|
new ResolvedAlarm
|
||||||
|
{
|
||||||
|
CanonicalName = "ExprAlarm",
|
||||||
|
TriggerType = "Expression",
|
||||||
|
TriggerConfiguration = "{\"expression\":\"true\"}",
|
||||||
|
PriorityLevel = 1
|
||||||
|
},
|
||||||
|
out _,
|
||||||
|
CompileTriggerExpression("true"));
|
||||||
|
|
||||||
|
alarm.Tell(new AttributeValueChanged(
|
||||||
|
"Pump1", "A", "A", 1, "Good", DateTimeOffset.UtcNow,
|
||||||
|
SourceExecutionId: writerExecutionId));
|
||||||
|
|
||||||
|
AwaitAssert(
|
||||||
|
() => Assert.Equal(writerExecutionId, alarm.UnderlyingActor.LastOnTriggerParentExecutionId),
|
||||||
|
TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hop 1 — ScriptRuntimeContext.SetAttribute stamps the running execution
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAttribute_StampsTheRunningExecutionIdOnTheWriteCommand()
|
||||||
|
{
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var executionId = Guid.NewGuid();
|
||||||
|
var context = new ScriptRuntimeContext(
|
||||||
|
probe.Ref,
|
||||||
|
ActorRefs.Nobody,
|
||||||
|
_sharedLibrary,
|
||||||
|
currentCallDepth: 0,
|
||||||
|
maxCallDepth: 10,
|
||||||
|
askTimeout: TimeSpan.FromSeconds(5),
|
||||||
|
instanceName: "Pump1",
|
||||||
|
logger: NullLogger.Instance,
|
||||||
|
executionId: executionId);
|
||||||
|
|
||||||
|
var write = context.SetAttribute("Status", "Critical");
|
||||||
|
|
||||||
|
var command = probe.ExpectMsg<SetStaticAttributeCommand>(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.Equal(executionId, command.SourceExecutionId);
|
||||||
|
|
||||||
|
probe.Reply(new SetStaticAttributeResponse(
|
||||||
|
command.CorrelationId, "Pump1", "Status", true, null, DateTimeOffset.UtcNow));
|
||||||
|
await write;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Hop 2 — Instance Actor carries the writer onto the published change
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_StaticWrite_PublishesTheWritingExecutionOnTheChange()
|
||||||
|
{
|
||||||
|
var streamManager = new SiteStreamManager(
|
||||||
|
new SiteRuntimeOptions { StreamBufferSize = 100 },
|
||||||
|
NullLogger<SiteStreamManager>.Instance);
|
||||||
|
streamManager.Initialize(Sys);
|
||||||
|
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump1",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var instance = ActorOf(Props.Create(() => new InstanceActor(
|
||||||
|
"Pump1",
|
||||||
|
JsonSerializer.Serialize(config),
|
||||||
|
_storage,
|
||||||
|
_compilationService,
|
||||||
|
_sharedLibrary,
|
||||||
|
streamManager,
|
||||||
|
_options,
|
||||||
|
NullLogger<InstanceActor>.Instance)));
|
||||||
|
|
||||||
|
var subscriber = CreateTestProbe();
|
||||||
|
streamManager.Subscribe("Pump1", subscriber.Ref);
|
||||||
|
|
||||||
|
var writerExecutionId = Guid.NewGuid();
|
||||||
|
instance.Tell(new SetStaticAttributeCommand(
|
||||||
|
"corr-1", "Pump1", "Status", "Critical", DateTimeOffset.UtcNow,
|
||||||
|
SourceExecutionId: writerExecutionId));
|
||||||
|
|
||||||
|
var published = subscriber.FishForMessage<AttributeValueChanged>(
|
||||||
|
m => m.AttributeName == "Status", TimeSpan.FromSeconds(10));
|
||||||
|
Assert.Equal(writerExecutionId, published.SourceExecutionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void InstanceActor_WriteWithNoAuditedOrigin_PublishesNoExecutionId()
|
||||||
|
{
|
||||||
|
var streamManager = new SiteStreamManager(
|
||||||
|
new SiteRuntimeOptions { StreamBufferSize = 100 },
|
||||||
|
NullLogger<SiteStreamManager>.Instance);
|
||||||
|
streamManager.Initialize(Sys);
|
||||||
|
|
||||||
|
var config = new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Pump2",
|
||||||
|
Attributes =
|
||||||
|
[
|
||||||
|
new ResolvedAttribute { CanonicalName = "Status", Value = "Normal", DataType = "String" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var instance = ActorOf(Props.Create(() => new InstanceActor(
|
||||||
|
"Pump2",
|
||||||
|
JsonSerializer.Serialize(config),
|
||||||
|
_storage,
|
||||||
|
_compilationService,
|
||||||
|
_sharedLibrary,
|
||||||
|
streamManager,
|
||||||
|
_options,
|
||||||
|
NullLogger<InstanceActor>.Instance)));
|
||||||
|
|
||||||
|
var subscriber = CreateTestProbe();
|
||||||
|
streamManager.Subscribe("Pump2", subscriber.Ref);
|
||||||
|
|
||||||
|
instance.Tell(new SetStaticAttributeCommand(
|
||||||
|
"corr-2", "Pump2", "Status", "Critical", DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
|
var published = subscriber.FishForMessage<AttributeValueChanged>(
|
||||||
|
m => m.AttributeName == "Status", TimeSpan.FromSeconds(10));
|
||||||
|
Assert.Null(published.SourceExecutionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,8 +35,10 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// The alarm on-trigger plumbing carries a <c>parentExecutionId</c> into the
|
/// The alarm on-trigger plumbing carries a <c>parentExecutionId</c> into the
|
||||||
/// script context — null today (the run is a root) but threaded so a future
|
/// script context, and the alarm run is itself a proper execution node whose
|
||||||
/// firing id can flow.
|
/// own <c>ExecutionId</c> cascades onward. Which firing execution the alarm run
|
||||||
|
/// is parented TO is covered separately by
|
||||||
|
/// <c>Actors.AlarmCascadeParentExecutionTests</c>.
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -240,11 +242,11 @@ public class ParentExecutionTreeTests : TestKit
|
|||||||
public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent()
|
public void AlarmOnTrigger_NestedCallScript_CarriesAlarmRunsOwnExecutionId_AsParent()
|
||||||
{
|
{
|
||||||
// End-to-end alarm plumbing: when an alarm fires, its on-trigger script
|
// End-to-end alarm plumbing: when an alarm fires, its on-trigger script
|
||||||
// runs in a ScriptRuntimeContext built by AlarmExecutionActor. With no
|
// runs in a ScriptRuntimeContext built by AlarmExecutionActor. The change
|
||||||
// Guid firing id today the alarm run is a ROOT (its own ParentExecutionId
|
// below carries no SourceExecutionId (it stands in for DCL data), so the
|
||||||
// is null), but it still mints its OWN fresh ExecutionId. A nested
|
// alarm run is a ROOT — but it still mints its OWN fresh ExecutionId. A
|
||||||
// CallScript from that on-trigger script must therefore carry the alarm
|
// nested CallScript from that on-trigger script must therefore carry the
|
||||||
// run's OWN (non-null) ExecutionId as the child's ParentExecutionId —
|
// alarm run's OWN (non-null) ExecutionId as the child's ParentExecutionId —
|
||||||
// proving the alarm context is a proper execution node feeding the
|
// proving the alarm context is a proper execution node feeding the
|
||||||
// cascade and the parentExecutionId parameter is plumbed end-to-end.
|
// cascade and the parentExecutionId parameter is plumbed end-to-end.
|
||||||
var compilationService = new ScriptCompilationService(
|
var compilationService = new ScriptCompilationService(
|
||||||
@@ -280,7 +282,7 @@ public class ParentExecutionTreeTests : TestKit
|
|||||||
var request = instanceProbe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
var request = instanceProbe.ExpectMsg<ScriptCallRequest>(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
Assert.Equal("Child", request.ScriptName);
|
Assert.Equal("Child", request.ScriptName);
|
||||||
// The alarm run is a root today (its own parent is null), but its OWN
|
// This alarm run is a root (no writer on the firing change), but its OWN
|
||||||
// freshly-minted ExecutionId cascades to the child — so the child's
|
// freshly-minted ExecutionId cascades to the child — so the child's
|
||||||
// ParentExecutionId is a real, non-empty value, NOT null.
|
// ParentExecutionId is a real, non-empty value, NOT null.
|
||||||
Assert.NotNull(request.ParentExecutionId);
|
Assert.NotNull(request.ParentExecutionId);
|
||||||
|
|||||||
Reference in New Issue
Block a user