Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer #190

Merged
dohertj2 merged 1 commits from phase-7-fu-243-compose into v2 2026-04-20 21:25:48 -04:00
Owner

Ships the composition kernel that maps Config DB rows (Script / VirtualTag / ScriptedAlarm) to the runtime definitions VirtualTagEngine + ScriptedAlarmEngine consume, builds the engine instances, and wires OnEvent → historian-sink routing.

New in src/ZB.MOM.WW.OtOpcUa.Server/Phase7/

  • CachedTagUpstreamSource — implements both Core.VirtualTags.ITagUpstreamSource and Core.ScriptedAlarms.ITagUpstreamSource on one concrete type so the composer can hand one instance to both engines. Thread-safe ConcurrentDictionary value cache with synchronous ReadTag + fire-on-write Push(path, snapshot) that fans out to every observer registered via SubscribeTag. Unknown-path reads return a BadNodeIdUnknown-quality snapshot.
  • Phase7EngineComposer.Compose(...) — single static entry point that indexes scripts by ScriptId, resolves VirtualTag.ScriptId + ScriptedAlarm.PredicateScriptId to full SourceCode, projects DB rows to VirtualTagDefinition + ScriptedAlarmDefinition (mapping DataType string → enum, AlarmTypeAlarmKind, Severity 1..1000 → AlarmSeverity band matching the OPC UA Part 9 bucket the other drivers already use), constructs + loads both engines, wires alarm OnEventIAlarmHistorianSink.EnqueueAsync, and returns a Phase7ComposedSources with the IReadable sources + an owned Disposables list.

Empty config returns Phase7ComposedSources.Empty so deployments without scripts/alarms behave exactly as pre-Phase-7. Non-null sources flow into OpcUaApplicationHost.virtualReadable / .scriptedAlarmReadable (plumbing landed by #239) → DriverNodeManager dispatches by NodeSourceKind (PR #186).

Tests — 12/12

CachedTagUpstreamSourceTests (6):

  • Unknown-path → BadNodeIdUnknown quality
  • Push → Read round-trip
  • Fan-out in registration order
  • Different-path push doesn't fire foreign observer
  • Disposing subscription stops fan-out
  • One instance satisfies both upstream interfaces

Phase7EngineComposerTests (6):

  • Empty rows → Phase7ComposedSources.Empty
  • VirtualTag rows → non-null VirtualReadable + populated Disposables
  • Missing script reference → InvalidOperationException with the missing ScriptId in the message
  • Disabled VirtualTag row skipped
  • TimerIntervalMsTimeSpan.FromMilliseconds round-trip
  • Severity 1..1000 maps to Low/Medium/High/Critical at 250/500/750 boundaries

What this PR does NOT do

The composition kernel is the tricky part; the remaining wiring is three narrower follow-ups:

  • #244 — driver-bridge feed populating CachedTagUpstreamSource from live driver subscriptions. Without this, ctx.GetTag returns BadNodeIdUnknown even when the driver has a fresh value.
  • #245ScriptedAlarmReadable adapter exposing each alarm's current Active state as IReadable. Currently returns null → reads on Source=ScriptedAlarm variables return BadNotFound per the ADR-002 misconfiguration signal.
  • #246Program.cs call to Phase7EngineComposer.Compose with config rows loaded from the sealed-cache DB read, plus SqliteStoreAndForwardSink lifecycle wiring + Galaxy.Host IPC writer adapter.

Task #240 (live E2E smoke) depends on all three landing.

Ships the composition kernel that maps Config DB rows (`Script` / `VirtualTag` / `ScriptedAlarm`) to the runtime definitions `VirtualTagEngine` + `ScriptedAlarmEngine` consume, builds the engine instances, and wires `OnEvent` → historian-sink routing. ## New in `src/ZB.MOM.WW.OtOpcUa.Server/Phase7/` - **`CachedTagUpstreamSource`** — implements both `Core.VirtualTags.ITagUpstreamSource` and `Core.ScriptedAlarms.ITagUpstreamSource` on one concrete type so the composer can hand one instance to both engines. Thread-safe `ConcurrentDictionary` value cache with synchronous `ReadTag` + fire-on-write `Push(path, snapshot)` that fans out to every observer registered via `SubscribeTag`. Unknown-path reads return a `BadNodeIdUnknown`-quality snapshot. - **`Phase7EngineComposer.Compose(...)`** — single static entry point that indexes scripts by `ScriptId`, resolves `VirtualTag.ScriptId` + `ScriptedAlarm.PredicateScriptId` to full `SourceCode`, projects DB rows to `VirtualTagDefinition` + `ScriptedAlarmDefinition` (mapping `DataType` string → enum, `AlarmType` → `AlarmKind`, Severity 1..1000 → `AlarmSeverity` band matching the OPC UA Part 9 bucket the other drivers already use), constructs + loads both engines, wires alarm `OnEvent` → `IAlarmHistorianSink.EnqueueAsync`, and returns a `Phase7ComposedSources` with the `IReadable` sources + an owned `Disposables` list. Empty config returns `Phase7ComposedSources.Empty` so deployments without scripts/alarms behave exactly as pre-Phase-7. Non-null sources flow into `OpcUaApplicationHost.virtualReadable` / `.scriptedAlarmReadable` (plumbing landed by #239) → `DriverNodeManager` dispatches by `NodeSourceKind` (PR #186). ## Tests — 12/12 `CachedTagUpstreamSourceTests` (6): - Unknown-path → `BadNodeIdUnknown` quality - Push → Read round-trip - Fan-out in registration order - Different-path push doesn't fire foreign observer - Disposing subscription stops fan-out - One instance satisfies both upstream interfaces `Phase7EngineComposerTests` (6): - Empty rows → `Phase7ComposedSources.Empty` - VirtualTag rows → non-null `VirtualReadable` + populated Disposables - Missing script reference → `InvalidOperationException` with the missing `ScriptId` in the message - Disabled VirtualTag row skipped - `TimerIntervalMs` → `TimeSpan.FromMilliseconds` round-trip - Severity 1..1000 maps to Low/Medium/High/Critical at 250/500/750 boundaries ## What this PR does NOT do The composition kernel is the tricky part; the remaining wiring is three narrower follow-ups: - **#244** — driver-bridge feed populating `CachedTagUpstreamSource` from live driver subscriptions. Without this, `ctx.GetTag` returns `BadNodeIdUnknown` even when the driver has a fresh value. - **#245** — `ScriptedAlarmReadable` adapter exposing each alarm's current Active state as `IReadable`. Currently returns null → reads on `Source=ScriptedAlarm` variables return `BadNotFound` per the ADR-002 misconfiguration signal. - **#246** — `Program.cs` call to `Phase7EngineComposer.Compose` with config rows loaded from the sealed-cache DB read, plus `SqliteStoreAndForwardSink` lifecycle wiring + Galaxy.Host IPC writer adapter. Task #240 (live E2E smoke) depends on all three landing.
dohertj2 added 1 commit 2026-04-20 21:25:39 -04:00
Ships the composition kernel that maps Config DB rows (Script / VirtualTag /
ScriptedAlarm) to the runtime definitions VirtualTagEngine + ScriptedAlarmEngine
consume, builds the engine instances, and wires OnEvent → historian-sink routing.

## src/ZB.MOM.WW.OtOpcUa.Server/Phase7/

- CachedTagUpstreamSource — implements both Core.VirtualTags.ITagUpstreamSource and
  Core.ScriptedAlarms.ITagUpstreamSource (identical shape, distinct namespaces) on one
  concrete type so the composer can hand one instance to both engines. Thread-safe
  ConcurrentDictionary value cache with synchronous ReadTag + fire-on-write
  Push(path, snapshot) that fans out to every observer registered via SubscribeTag.
  Unknown-path reads return a BadNodeIdUnknown-quality snapshot (status 0x80340000)
  so scripts branch on quality naturally.
- Phase7EngineComposer.Compose(scripts, virtualTags, scriptedAlarms, upstream,
  alarmStateStore, historianSink, rootScriptLogger, loggerFactory) — single static
  entry point that:
  * Indexes scripts by ScriptId, resolves VirtualTag.ScriptId + ScriptedAlarm.PredicateScriptId
    to full SourceCode
  * Projects DB rows to VirtualTagDefinition + ScriptedAlarmDefinition (mapping
    DataType string → DriverDataType enum, AlarmType string → AlarmKind enum,
    Severity 1..1000 → AlarmSeverity bucket matching the OPC UA Part 9 bands
    that AbCipAlarmProjection + OpcUaClient MapSeverity already use)
  * Constructs VirtualTagEngine + loads definitions (throws InvalidOperationException
    with the list of scripts that failed to compile — aggregated like Streams B+C)
  * Constructs ScriptedAlarmEngine + loads definitions + wires OnEvent →
    IAlarmHistorianSink.EnqueueAsync using ScriptedAlarmEvent.Emission as the event
    kind + Condition.LastAckUser/LastAckComment for audit fields
  * Returns Phase7ComposedSources with Disposables list the caller owns

Empty Phase 7 config returns Phase7ComposedSources.Empty so deployments without
scripts / alarms behave exactly as pre-Phase-7. Non-null sources flow into
OpcUaApplicationHost's virtualReadable / scriptedAlarmReadable plumbing landed by
task #239 — DriverNodeManager then dispatches reads by NodeSourceKind per PR #186.

## Tests — 12/12

CachedTagUpstreamSourceTests (6):
- Unknown-path read returns BadNodeIdUnknown-quality snapshot
- Push-then-Read returns cached value
- Push fans out to subscribers in registration order
- Push to one path doesn't fire another path's observer
- Dispose of subscription handle stops fan-out
- Satisfies both Core.VirtualTags + Core.ScriptedAlarms ITagUpstreamSource interfaces

Phase7EngineComposerTests (6):
- Empty rows → Phase7ComposedSources.Empty (both sources null)
- VirtualTag rows → VirtualReadable non-null + Disposables populated
- Missing script reference throws InvalidOperationException with the missing ScriptId
  in the message
- Disabled VirtualTag row skipped by projection
- TimerIntervalMs → TimeSpan.FromMilliseconds round-trip
- Severity 1..1000 maps to Low/Medium/High/Critical at 250/500/750 boundaries
  (matches AbCipAlarmProjection + OpcUaClient.MapSeverity banding)

## Scope — what this PR does NOT do

The composition kernel is the tricky part; the remaining wiring is three narrower
follow-ups that each build on this PR:

- task #244 — driver-bridge feed that populates CachedTagUpstreamSource from live
  driver subscriptions. Without this, ctx.GetTag returns BadNodeIdUnknown even when
  the driver has a fresh value.
- task #245 — ScriptedAlarmReadable adapter exposing each alarm's current Active
  state as IReadable. Phase7EngineComposer.Compose currently returns
  ScriptedAlarmReadable=null so reads on Source=ScriptedAlarm variables return
  BadNotFound per the ADR-002 "misconfiguration not silent fallback" signal.
- task #246 — Program.cs call to Phase7EngineComposer.Compose with config rows
  loaded from the sealed-cache DB read, plus SqliteStoreAndForwardSink lifecycle
  wiring at %ProgramData%/OtOpcUa/alarm-historian-queue.db with the Galaxy.Host
  IPC writer from Stream D.

Task #240 (live OPC UA E2E smoke) depends on all three follow-ups landing.
dohertj2 merged commit f3053580a0 into v2 2026-04-20 21:25:48 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#190