diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 9f3ad1f2..010aad9a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -173,6 +173,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers _currentRevision = revision; _log.Info("DriverHost {Node}: recovered Applied state at rev {Rev}", _localNode, revision); Become(Steady); + // The revision is recovered but the in-memory driver children + OPC UA address + // space were lost on restart. Re-spawn + re-materialise + re-subscribe from the + // applied deployment so a restarted/rebuilt node restores its served state instead + // of waiting for a config change (whose identical-config revision would no-op). + RestoreApplied(new DeploymentId(latest.DeploymentId)); break; case NodeDeploymentStatus.Applying: _log.Warning("DriverHost {Node}: found orphan Applying row for deployment {Id}; replaying", @@ -310,7 +315,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers // Trigger the OPC UA address-space rebuild so the local SDK reflects the new // composition. The publish actor handles the load-compose-diff-apply pipeline; we // just forward the same correlation id so the audit trail joins up. - _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation)); + _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId)); // SubscribeBulk pass: hand each driver its desired tag references so live values flow into // the just-rebuilt address space instead of staying BadWaitingForInitialData. PushDesiredSubscriptions(deploymentId); @@ -371,6 +376,31 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers foreach (var spec in plan.ToSpawn) SpawnChild(spec); } + /// + /// Restore the served state for an already-applied deployment after a process restart. + /// recovers from NodeDeploymentState, + /// but the driver children and OPC UA address space are in-memory and gone after a restart — + /// so without this a restarted node serves an empty address space until the next config + /// change (and an identical-config redeploy no-ops on the unchanged revision). Re-spawns + /// drivers, rebuilds the address space from the applied artifact, and re-pushes SubscribeBulk. + /// No re-ack: the deployment is already Applied. + /// + private void RestoreApplied(DeploymentId deploymentId) + { + var correlation = CorrelationId.NewId(); + try + { + ReconcileDrivers(deploymentId); + _opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId)); + PushDesiredSubscriptions(deploymentId); + _log.Info("DriverHost {Node}: restored served state for applied deployment {Id} on bootstrap", _localNode, deploymentId); + } + catch (Exception ex) + { + _log.Warning(ex, "DriverHost {Node}: failed to restore served state for {Id} on bootstrap", _localNode, deploymentId); + } + } + /// /// SubscribeBulk pass. After an apply, read the deployment's SystemPlatform / Galaxy tags, /// group their dot-form MXAccess references by driver instance, and hand each running driver diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 6fed3160..ff8e5ecc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -31,7 +31,13 @@ public sealed class OpcUaPublishActor : ReceiveActor public sealed record AttributeValueUpdate(string NodeId, object? Value, OpcUaQuality Quality, DateTime TimestampUtc); public sealed record AlarmStateUpdate(string AlarmNodeId, bool Active, bool Acknowledged, DateTime TimestampUtc); - public sealed record RebuildAddressSpace(CorrelationId Correlation); + /// + /// Triggers an address-space rebuild. is the deployment + /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the + /// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which + /// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision). + /// + public sealed record RebuildAddressSpace(CorrelationId Correlation, DeploymentId? DeploymentId = null); public sealed record ServiceLevelChanged(byte ServiceLevel); private readonly IOpcUaAddressSpaceSink _sink; @@ -196,7 +202,13 @@ public sealed class OpcUaPublishActor : ReceiveActor try { - var artifact = LoadLatestArtifact(); + // Prefer the artifact of the deployment the host just applied — at apply time it is not + // yet Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a + // stale composition (variables that don't match the SubscribeBulk refs). Fall back to + // latest-sealed only for legacy callers that don't carry a DeploymentId. + var artifact = msg.DeploymentId is { } depId + ? LoadArtifact(depId) + : LoadLatestArtifact(); var composition = DeploymentArtifact.ParseComposition(artifact); var plan = Phase7Planner.Compute(_lastApplied, composition); @@ -229,6 +241,25 @@ public sealed class OpcUaPublishActor : ReceiveActor } } + /// Read a specific deployment's artifact blob from ConfigDb (the one just applied, + /// which may not be Sealed yet). Empty array on any failure — parser treats it as "no composition". + private byte[] LoadArtifact(DeploymentId deploymentId) + { + try + { + using var db = _dbFactory!.CreateDbContext(); + return db.Deployments.AsNoTracking() + .Where(d => d.DeploymentId == deploymentId.Value) + .Select(d => d.ArtifactBlob) + .FirstOrDefault() ?? Array.Empty(); + } + catch (Exception ex) + { + _log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; rebuild becomes no-op", deploymentId); + return Array.Empty(); + } + } + /// Read the most recent Sealed deployment's artifact blob from ConfigDb. /// Empty array on any failure — the parser treats empty blob as "no composition". private byte[] LoadLatestArtifact()