Phase 7 follow-up #243 — CachedTagUpstreamSource + Phase7EngineComposer #190
Reference in New Issue
Block a user
Delete Branch "phase-7-fu-243-compose"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Ships the composition kernel that maps Config DB rows (
Script/VirtualTag/ScriptedAlarm) to the runtime definitionsVirtualTagEngine+ScriptedAlarmEngineconsume, builds the engine instances, and wiresOnEvent→ historian-sink routing.New in
src/ZB.MOM.WW.OtOpcUa.Server/Phase7/CachedTagUpstreamSource— implements bothCore.VirtualTags.ITagUpstreamSourceandCore.ScriptedAlarms.ITagUpstreamSourceon one concrete type so the composer can hand one instance to both engines. Thread-safeConcurrentDictionaryvalue cache with synchronousReadTag+ fire-on-writePush(path, snapshot)that fans out to every observer registered viaSubscribeTag. Unknown-path reads return aBadNodeIdUnknown-quality snapshot.Phase7EngineComposer.Compose(...)— single static entry point that indexes scripts byScriptId, resolvesVirtualTag.ScriptId+ScriptedAlarm.PredicateScriptIdto fullSourceCode, projects DB rows toVirtualTagDefinition+ScriptedAlarmDefinition(mappingDataTypestring → enum,AlarmType→AlarmKind, Severity 1..1000 →AlarmSeverityband matching the OPC UA Part 9 bucket the other drivers already use), constructs + loads both engines, wires alarmOnEvent→IAlarmHistorianSink.EnqueueAsync, and returns aPhase7ComposedSourceswith theIReadablesources + an ownedDisposableslist.Empty config returns
Phase7ComposedSources.Emptyso deployments without scripts/alarms behave exactly as pre-Phase-7. Non-null sources flow intoOpcUaApplicationHost.virtualReadable/.scriptedAlarmReadable(plumbing landed by #239) →DriverNodeManagerdispatches byNodeSourceKind(PR #186).Tests — 12/12
CachedTagUpstreamSourceTests(6):BadNodeIdUnknownqualityPhase7EngineComposerTests(6):Phase7ComposedSources.EmptyVirtualReadable+ populated DisposablesInvalidOperationExceptionwith the missingScriptIdin the messageTimerIntervalMs→TimeSpan.FromMillisecondsround-tripWhat this PR does NOT do
The composition kernel is the tricky part; the remaining wiring is three narrower follow-ups:
CachedTagUpstreamSourcefrom live driver subscriptions. Without this,ctx.GetTagreturnsBadNodeIdUnknowneven when the driver has a fresh value.ScriptedAlarmReadableadapter exposing each alarm's current Active state asIReadable. Currently returns null → reads onSource=ScriptedAlarmvariables returnBadNotFoundper the ADR-002 misconfiguration signal.Program.cscall toPhase7EngineComposer.Composewith config rows loaded from the sealed-cache DB read, plusSqliteStoreAndForwardSinklifecycle 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. ## 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.