perf(site-runtime): deserialize a deploy's flattened config once and hand the parsed object down (review 2026-10-21 01/SR-P5)
The deploy compile warm parses once off-thread; the parsed FlattenedConfiguration rides DeployCompileWarmed to the gate (new Validate(FlattenedConfiguration) overload), the redeploy buffer, CreateInstanceActor's DCL ensure and a new InstanceActor constructor overload. Stored-row paths parse once inside CreateInstanceActor; an unparseable stored row still fails init via S6.
This commit is contained in:
@@ -160,6 +160,8 @@ flowchart TD
|
|||||||
|
|
||||||
> **Dead-child bookkeeping + two-signal deploy join (S6).** All Instance Actors are `Watch`ed; an unexpected `Terminated` evicts the actor ref from the singleton's map. **Deviation from the naive design (2026-07-09, see master-tracker "Plan deviations during Wave 1 PLAN-03 execution"):** deploy-success is **NOT** reported on persistence alone — the config store can commit before the Instance Actor's asynchronous `PreStart` throws. The design therefore uses a **two-signal join**: the Instance Actor sends a new `InstanceActorInitialized` readiness message at the end of a successful `PreStart`, and the Deployment Manager reports `Success` only once **both** persistence has committed **and** that readiness signal has arrived. An actor that dies during init never signals readiness → the `Terminated` fallback replies `Failed` and rolls the optimistic in-memory state back (the persisted-row rollback is deferred until the store commits so it cannot race the write).
|
> **Dead-child bookkeeping + two-signal deploy join (S6).** All Instance Actors are `Watch`ed; an unexpected `Terminated` evicts the actor ref from the singleton's map. **Deviation from the naive design (2026-07-09, see master-tracker "Plan deviations during Wave 1 PLAN-03 execution"):** deploy-success is **NOT** reported on persistence alone — the config store can commit before the Instance Actor's asynchronous `PreStart` throws. The design therefore uses a **two-signal join**: the Instance Actor sends a new `InstanceActorInitialized` readiness message at the end of a successful `PreStart`, and the Deployment Manager reports `Success` only once **both** persistence has committed **and** that readiness signal has arrived. An actor that dies during init never signals readiness → the `Terminated` fallback replies `Failed` and rolls the optimistic in-memory state back (the persisted-row rollback is deferred until the store commits so it cannot race the write).
|
||||||
|
|
||||||
|
> **A deploy's config is deserialized once (review 2026-10-21 01/SR-P5).** The compile warm, the synchronous gate, the DCL-connection ensure and the Instance Actor constructor each used to `JsonSerializer.Deserialize<FlattenedConfiguration>` the same ~70 KB JSON — four object graphs per instance, two of them on the singleton's own thread. The warm now parses it once off-thread (`DeployCompileValidator.TryParse`), and the parsed object rides `DeployCompileWarmed` → the gate (`Validate(FlattenedConfiguration, …)`) → the redeploy buffer → `CreateInstanceActor` → a new `InstanceActor` constructor overload that takes the parsed configuration (read-only, so sharing it is safe; the Props no longer retain the JSON string either). A config that does not parse is still the gate's readable `Configuration JSON failed to deserialize` rejection. Paths that start from a stored row (enable, self-heal, boot) parse once inside `CreateInstanceActor`; a stored row that does not parse is handed to the actor as JSON, so it still fails init through the S6 path exactly as before.
|
||||||
|
|
||||||
> **Runtime names are validated before anything is applied (review 2026-10-21 01/SR-C1).** The instance unique name becomes the Instance Actor's name, and every script / alarm / native-alarm-source canonical name becomes a child actor name (`script-…`, `alarm-…`, `native-alarm-…`). Akka refuses any of these that is not a legal path element (a space, `#`, `/`, `[`, `]`, a bare `%`, a non-ASCII letter, a leading `$`), and the refusal is a **synchronous** `InvalidActorNameException` from `Context.ActorOf` — on the Deployment Manager singleton's own thread for an instance name, where the default Restart directive used to stop **every** Instance Actor on the site, lose all in-flight deploy bookkeeping, and leave the deployer's Ask to time out (so a retry repeated the outage). The site compile gate (`DeployCompileValidator`) now checks all of those names with Akka's own `ActorPath.IsValidPathElement` and rejects the deploy with `Site deploy validation failed: Invalid runtime name: …` before any reset, persistence or actor work. It deliberately does **not** decide with the Commons mirror `ActorNameRules`: the mirror is currently stricter than Akka (it refuses `"`, `(` and `)`), so gating on it would reject names that deploy today. Behind the gate, `CreateInstanceActor` catches the exception and returns a failure that each caller reports — a Failed deploy, a failed enable, an Error site event for a self-heal start, a skipped instance in the boot batch — so a row stored before names were checked can never escalate to the singleton. The Instance Actor applies the same rule to its children: a refused child name is logged and that one child skipped, exactly like a compile failure, instead of failing the whole instance's `PreStart`.
|
> **Runtime names are validated before anything is applied (review 2026-10-21 01/SR-C1).** The instance unique name becomes the Instance Actor's name, and every script / alarm / native-alarm-source canonical name becomes a child actor name (`script-…`, `alarm-…`, `native-alarm-…`). Akka refuses any of these that is not a legal path element (a space, `#`, `/`, `[`, `]`, a bare `%`, a non-ASCII letter, a leading `$`), and the refusal is a **synchronous** `InvalidActorNameException` from `Context.ActorOf` — on the Deployment Manager singleton's own thread for an instance name, where the default Restart directive used to stop **every** Instance Actor on the site, lose all in-flight deploy bookkeeping, and leave the deployer's Ask to time out (so a retry repeated the outage). The site compile gate (`DeployCompileValidator`) now checks all of those names with Akka's own `ActorPath.IsValidPathElement` and rejects the deploy with `Site deploy validation failed: Invalid runtime name: …` before any reset, persistence or actor work. It deliberately does **not** decide with the Commons mirror `ActorNameRules`: the mirror is currently stricter than Akka (it refuses `"`, `(` and `)`), so gating on it would reject names that deploy today. Behind the gate, `CreateInstanceActor` catches the exception and returns a failure that each caller reports — a Failed deploy, a failed enable, an Error site event for a self-heal start, a skipped instance in the boot batch — so a row stored before names were checked can never escalate to the singleton. The Instance Actor applies the same rule to its children: a refused child name is logged and that one child skipped, exactly like a compile failure, instead of failing the whole instance's `PreStart`.
|
||||||
|
|
||||||
### System-Wide Artifact Handling
|
### System-Wide Artifact Handling
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
|||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
|
||||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
||||||
@@ -985,9 +986,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
var configJson = command.FlattenedConfigurationJson;
|
var configJson = command.FlattenedConfigurationJson;
|
||||||
Task.Run(() =>
|
Task.Run(() =>
|
||||||
{
|
{
|
||||||
|
// Review 2026-10-21 01/SR-P5: this is the ONE deserialization of the deploy's config.
|
||||||
|
// The parsed object rides back on DeployCompileWarmed to the gate, the DCL-connection
|
||||||
|
// ensure and the Instance Actor, which each used to parse the same JSON again — two
|
||||||
|
// of those parses on this singleton's own thread.
|
||||||
|
if (!DeployCompileValidator.TryParse(configJson, out var parsed, out var parseError))
|
||||||
|
return new DeployCompileWarmed(command, null, parseError);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
validator.Validate(configJson);
|
validator.Validate(parsed!);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -996,7 +1004,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
command.InstanceUniqueName);
|
command.InstanceUniqueName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DeployCompileWarmed(command);
|
return new DeployCompileWarmed(command, parsed, null);
|
||||||
}).PipeTo(Self);
|
}).PipeTo(Self);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,7 +1027,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
_deployWarms.Remove(instanceName);
|
_deployWarms.Remove(instanceName);
|
||||||
|
|
||||||
RunDeployGateAndProceed(warming.Command, warming.ReplyTo);
|
RunDeployGateAndProceed(warming.Command, warming.ReplyTo, msg.Parsed, msg.ParseError);
|
||||||
|
|
||||||
foreach (var queued in warming.Buffered)
|
foreach (var queued in warming.Buffered)
|
||||||
DispatchBufferedCommand(queued);
|
DispatchBufferedCommand(queued);
|
||||||
@@ -1072,7 +1080,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
/// only from <see cref="HandleDeployCompileWarmed"/>, i.e. after the same compile has
|
/// only from <see cref="HandleDeployCompileWarmed"/>, i.e. after the same compile has
|
||||||
/// been warmed off-thread.
|
/// been warmed off-thread.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void RunDeployGateAndProceed(DeployInstanceCommand command, IActorRef replyTo)
|
/// <param name="command">The deploy command being gated.</param>
|
||||||
|
/// <param name="replyTo">The deployer to answer.</param>
|
||||||
|
/// <param name="parsed">The config parsed once by the warm (01/SR-P5), or null on a parse failure.</param>
|
||||||
|
/// <param name="parseError">The warm's deserialization error, when <paramref name="parsed"/> is null.</param>
|
||||||
|
private void RunDeployGateAndProceed(
|
||||||
|
DeployInstanceCommand command, IActorRef replyTo,
|
||||||
|
FlattenedConfiguration? parsed, string? parseError)
|
||||||
{
|
{
|
||||||
// Site-side compile gate (S3): a compile failure must reject the
|
// Site-side compile gate (S3): a compile failure must reject the
|
||||||
// deployment with NO partial state applied (no Instance Actor, no
|
// deployment with NO partial state applied (no Instance Actor, no
|
||||||
@@ -1092,8 +1106,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
// The gate also rejects any instance/script/alarm/native-source name that is not a legal
|
// The gate also rejects any instance/script/alarm/native-source name that is not a legal
|
||||||
// Akka actor name (review 2026-10-21 01/SR-C1) — such a name used to throw out of
|
// Akka actor name (review 2026-10-21 01/SR-C1) — such a name used to throw out of
|
||||||
// Context.ActorOf on THIS actor's thread and restart the singleton with every instance.
|
// Context.ActorOf on THIS actor's thread and restart the singleton with every instance.
|
||||||
var errors = _deployCompileValidator.Validate(
|
var errors = parsed == null
|
||||||
command.FlattenedConfigurationJson, command.InstanceUniqueName);
|
? [parseError ?? "Configuration JSON failed to deserialize."]
|
||||||
|
: _deployCompileValidator.Validate(parsed, command.InstanceUniqueName);
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
{
|
{
|
||||||
var instanceName = command.InstanceUniqueName;
|
var instanceName = command.InstanceUniqueName;
|
||||||
@@ -1109,14 +1124,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProceedWithDeploy(command, replyTo);
|
ProceedWithDeploy(command, replyTo, parsed!);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Core deploy application (redeploy-supersede + fresh apply), reached only
|
/// Core deploy application (redeploy-supersede + fresh apply), reached only
|
||||||
/// after the synchronous compile gate in <see cref="HandleDeploy"/> passes.
|
/// after the synchronous compile gate in <see cref="HandleDeploy"/> passes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ProceedWithDeploy(DeployInstanceCommand command, IActorRef replyTo)
|
private void ProceedWithDeploy(
|
||||||
|
DeployInstanceCommand command, IActorRef replyTo, FlattenedConfiguration parsed)
|
||||||
{
|
{
|
||||||
var instanceName = command.InstanceUniqueName;
|
var instanceName = command.InstanceUniqueName;
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
@@ -1129,7 +1145,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
if (_instanceActors.TryGetValue(instanceName, out var existing))
|
if (_instanceActors.TryGetValue(instanceName, out var existing))
|
||||||
{
|
{
|
||||||
_instanceActors.Remove(instanceName);
|
_instanceActors.Remove(instanceName);
|
||||||
_pendingRedeploys[existing] = new PendingRedeploy(command, replyTo);
|
_pendingRedeploys[existing] = new PendingRedeploy(command, replyTo, parsed);
|
||||||
_terminatingActorsByName[instanceName] = existing;
|
_terminatingActorsByName[instanceName] = existing;
|
||||||
Context.Watch(existing);
|
Context.Watch(existing);
|
||||||
_redeployStopQueue.Enqueue(existing);
|
_redeployStopQueue.Enqueue(existing);
|
||||||
@@ -1157,12 +1173,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
$"superseded by newer deployment {command.DeploymentId} before predecessor finished terminating",
|
$"superseded by newer deployment {command.DeploymentId} before predecessor finished terminating",
|
||||||
DateTimeOffset.UtcNow));
|
DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
_pendingRedeploys[terminatingRef] = new PendingRedeploy(command, replyTo);
|
_pendingRedeploys[terminatingRef] = new PendingRedeploy(command, replyTo, parsed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh deployment — no existing actor to replace.
|
// Fresh deployment — no existing actor to replace.
|
||||||
ApplyDeployment(command, replyTo, isRedeploy: false);
|
ApplyDeployment(command, replyTo, isRedeploy: false, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool DeferLifecycleStop(string name, string commandId, bool delete, IActorRef replyTo)
|
private bool DeferLifecycleStop(string name, string commandId, bool delete, IActorRef replyTo)
|
||||||
@@ -1224,7 +1240,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
_terminatingActorsByName.Remove(pending.Command.InstanceUniqueName);
|
_terminatingActorsByName.Remove(pending.Command.InstanceUniqueName);
|
||||||
if (flush.Error is null)
|
if (flush.Error is null)
|
||||||
ApplyDeployment(pending.Command, pending.OriginalSender, isRedeploy: true);
|
ApplyDeployment(pending.Command, pending.OriginalSender, isRedeploy: true, pending.Parsed);
|
||||||
else
|
else
|
||||||
pending.OriginalSender.Tell(new DeploymentStatusResponse(pending.Command.DeploymentId,
|
pending.OriginalSender.Tell(new DeploymentStatusResponse(pending.Command.DeploymentId,
|
||||||
pending.Command.InstanceUniqueName, DeploymentStatus.Failed,
|
pending.Command.InstanceUniqueName, DeploymentStatus.Failed,
|
||||||
@@ -1381,7 +1397,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
/// incorrect. The reply is sent from <see cref="HandleDeployPersistenceResult"/> once
|
/// incorrect. The reply is sent from <see cref="HandleDeployPersistenceResult"/> once
|
||||||
/// the persistence outcome is known.
|
/// the persistence outcome is known.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void ApplyDeployment(DeployInstanceCommand command, IActorRef sender, bool isRedeploy)
|
private void ApplyDeployment(
|
||||||
|
DeployInstanceCommand command, IActorRef sender, bool isRedeploy, FlattenedConfiguration? parsed)
|
||||||
{
|
{
|
||||||
var instanceName = command.InstanceUniqueName;
|
var instanceName = command.InstanceUniqueName;
|
||||||
|
|
||||||
@@ -1413,7 +1430,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
// connections this config's data-sourced attributes subscribe through (register row 85).
|
// connections this config's data-sourced attributes subscribe through (register row 85).
|
||||||
if (!CreateInstanceActor(
|
if (!CreateInstanceActor(
|
||||||
instanceName, command.FlattenedConfigurationJson, command.RevisionHash,
|
instanceName, command.FlattenedConfigurationJson, command.RevisionHash,
|
||||||
out var createError, staticStateResetGate))
|
out var createError, staticStateResetGate, parsed))
|
||||||
{
|
{
|
||||||
// Unreachable while the name pre-check above holds; kept so an ActorOf refusal can
|
// Unreachable while the name pre-check above holds; kept so an ActorOf refusal can
|
||||||
// never escape to the supervisor and restart the singleton.
|
// never escape to the supervisor and restart the singleton.
|
||||||
@@ -2888,10 +2905,32 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
{
|
{
|
||||||
if (_dclManager == null) return;
|
if (_dclManager == null) return;
|
||||||
|
|
||||||
|
FlattenedConfiguration? config;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var config = System.Text.Json.JsonSerializer.Deserialize<Commons.Types.Flattening.FlattenedConfiguration>(configJson);
|
config = System.Text.Json.JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
||||||
if (config?.Connections == null) return;
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse flattened config for DCL connections");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config != null)
|
||||||
|
EnsureDclConnections(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="EnsureDclConnections(string)"/> over an already-parsed configuration
|
||||||
|
/// (review 2026-10-21 01/SR-P5) — the form every creation path now uses.
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureDclConnections(FlattenedConfiguration config)
|
||||||
|
{
|
||||||
|
if (_dclManager == null) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (config.Connections == null) return;
|
||||||
|
|
||||||
foreach (var (name, connConfig) in config.Connections)
|
foreach (var (name, connConfig) in config.Connections)
|
||||||
{
|
{
|
||||||
@@ -2928,7 +2967,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to parse flattened config for DCL connections");
|
_logger.LogWarning(ex, "Failed to apply flattened config DCL connections");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3745,15 +3784,27 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
/// <see langword="null"/> on every non-deploy start (boot batch, enable, self-heal), where
|
/// <see langword="null"/> on every non-deploy start (boot batch, enable, self-heal), where
|
||||||
/// nothing is being reset and the load runs immediately as before.
|
/// nothing is being reset and the load runs immediately as before.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="parsed">
|
||||||
|
/// <paramref name="configJson"/> already deserialized by the caller (the deploy warm or the
|
||||||
|
/// boot-batch warm — review 2026-10-21 01/SR-P5), or <see langword="null"/> to parse it here,
|
||||||
|
/// ONCE, for both the DCL-connection ensure and the Instance Actor.
|
||||||
|
/// </param>
|
||||||
/// <returns>
|
/// <returns>
|
||||||
/// <see langword="false"/> when Akka refused the actor name (review 2026-10-21 01/SR-C1);
|
/// <see langword="false"/> when Akka refused the actor name (review 2026-10-21 01/SR-C1);
|
||||||
/// nothing was created or recorded. Callers turn that into a Failed reply / site event.
|
/// nothing was created or recorded. Callers turn that into a Failed reply / site event.
|
||||||
/// </returns>
|
/// </returns>
|
||||||
internal bool CreateInstanceActor(
|
internal bool CreateInstanceActor(
|
||||||
string instanceName, string configJson, string revisionHash, out string? error,
|
string instanceName, string configJson, string revisionHash, out string? error,
|
||||||
Task? staticStateResetGate = null)
|
Task? staticStateResetGate = null, FlattenedConfiguration? parsed = null)
|
||||||
{
|
{
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
|
// 01/SR-P5: one parse for the DCL ensure AND the actor. A config that does not parse is
|
||||||
|
// NOT handled here: the JSON is handed to the Instance Actor unchanged, whose constructor
|
||||||
|
// throws exactly as before (ActorInitializationException → Stop → the S6 path), so the
|
||||||
|
// failure semantics of a poison stored config are unchanged.
|
||||||
|
if (parsed == null && !DeployCompileValidator.TryParse(configJson, out parsed, out _))
|
||||||
|
parsed = null;
|
||||||
// Deferred register row 85 — an Instance Actor is useless without the DCL connection
|
// Deferred register row 85 — an Instance Actor is useless without the DCL connection
|
||||||
// actors its data-sourced attributes subscribe through, so ensuring them belongs in the
|
// actors its data-sourced attributes subscribe through, so ensuring them belongs in the
|
||||||
// single creation funnel rather than at each caller.
|
// single creation funnel rather than at each caller.
|
||||||
@@ -3777,7 +3828,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
// their exact previous semantics (they called it unconditionally). It is cheap and
|
// their exact previous semantics (they called it unconditionally). It is cheap and
|
||||||
// silent when there is nothing to do: EnsureDclConnection is hash-guarded on
|
// silent when there is nothing to do: EnsureDclConnection is hash-guarded on
|
||||||
// _createdConnections, so an unchanged connection is a no-op with no message and no log.
|
// _createdConnections, so an unchanged connection is a no-op with no message and no log.
|
||||||
EnsureDclConnections(configJson);
|
if (parsed != null)
|
||||||
|
EnsureDclConnections(parsed);
|
||||||
|
else
|
||||||
|
EnsureDclConnections(configJson);
|
||||||
|
|
||||||
if (_instanceActors.ContainsKey(instanceName))
|
if (_instanceActors.ContainsKey(instanceName))
|
||||||
{
|
{
|
||||||
@@ -3787,20 +3841,36 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
|
|
||||||
// Reuse the shared, host-configured logger factory
|
// Reuse the shared, host-configured logger factory
|
||||||
// instead of allocating (and leaking) a fresh LoggerFactory per instance.
|
// instead of allocating (and leaking) a fresh LoggerFactory per instance.
|
||||||
var props = Props.Create(() => new InstanceActor(
|
var instanceLogger = _loggerFactory.CreateLogger<InstanceActor>();
|
||||||
instanceName,
|
var props = parsed != null
|
||||||
configJson,
|
? Props.Create(() => new InstanceActor(
|
||||||
_storage,
|
instanceName,
|
||||||
_compilationService,
|
parsed,
|
||||||
_sharedScriptLibrary,
|
_storage,
|
||||||
_streamManager,
|
_compilationService,
|
||||||
_options,
|
_sharedScriptLibrary,
|
||||||
_loggerFactory.CreateLogger<InstanceActor>(),
|
_streamManager,
|
||||||
_dclManager,
|
_options,
|
||||||
_healthCollector,
|
instanceLogger,
|
||||||
_serviceProvider,
|
_dclManager,
|
||||||
null,
|
_healthCollector,
|
||||||
staticStateResetGate));
|
_serviceProvider,
|
||||||
|
null,
|
||||||
|
staticStateResetGate))
|
||||||
|
: Props.Create(() => new InstanceActor(
|
||||||
|
instanceName,
|
||||||
|
configJson,
|
||||||
|
_storage,
|
||||||
|
_compilationService,
|
||||||
|
_sharedScriptLibrary,
|
||||||
|
_streamManager,
|
||||||
|
_options,
|
||||||
|
instanceLogger,
|
||||||
|
_dclManager,
|
||||||
|
_healthCollector,
|
||||||
|
_serviceProvider,
|
||||||
|
null,
|
||||||
|
staticStateResetGate));
|
||||||
|
|
||||||
// Review 2026-10-21 01/SR-C1: ActorOf throws InvalidActorNameException SYNCHRONOUSLY on
|
// Review 2026-10-21 01/SR-C1: ActorOf throws InvalidActorNameException SYNCHRONOUSLY on
|
||||||
// this actor's thread for a name Akka refuses (space, '#', '/', non-ASCII, ...). Escaping
|
// this actor's thread for a name Akka refuses (space, '#', '/', non-ASCII, ...). Escaping
|
||||||
@@ -3978,7 +4048,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
/// command instance it warmed so a result superseded mid-warm is recognised by reference
|
/// command instance it warmed so a result superseded mid-warm is recognised by reference
|
||||||
/// and dropped.
|
/// and dropped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record DeployCompileWarmed(DeployInstanceCommand Command);
|
/// <param name="Command">The exact command instance the warm ran for.</param>
|
||||||
|
/// <param name="Parsed">The config parsed once off-thread (01/SR-P5); null when it did not parse.</param>
|
||||||
|
/// <param name="ParseError">Why it did not parse, when <paramref name="Parsed"/> is null.</param>
|
||||||
|
internal sealed record DeployCompileWarmed(
|
||||||
|
DeployInstanceCommand Command, FlattenedConfiguration? Parsed = null, string? ParseError = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Piped back to self once a staggered startup batch's compiles have been
|
/// Piped back to self once a staggered startup batch's compiles have been
|
||||||
@@ -4100,7 +4174,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// A redeployment command buffered until the previous Instance Actor terminates.
|
/// A redeployment command buffered until the previous Instance Actor terminates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal record PendingRedeploy(DeployInstanceCommand Command, IActorRef OriginalSender);
|
/// <param name="Command">The buffered deploy.</param>
|
||||||
|
/// <param name="OriginalSender">The deployer to answer.</param>
|
||||||
|
/// <param name="Parsed">The config the gate already parsed (01/SR-P5), reused on apply.</param>
|
||||||
|
internal record PendingRedeploy(
|
||||||
|
DeployInstanceCommand Command, IActorRef OriginalSender, FlattenedConfiguration? Parsed = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Join state for a deployment awaiting confirmation. Deployment <c>Success</c> is
|
/// Join state for a deployment awaiting confirmation. Deployment <c>Success</c> is
|
||||||
|
|||||||
@@ -262,6 +262,50 @@ public class InstanceActor : ReceiveActor
|
|||||||
IServiceProvider? serviceProvider = null,
|
IServiceProvider? serviceProvider = null,
|
||||||
TimeSpan? staticOverrideFlushInterval = null,
|
TimeSpan? staticOverrideFlushInterval = null,
|
||||||
Task? staticStateResetGate = null)
|
Task? staticStateResetGate = null)
|
||||||
|
: this(
|
||||||
|
instanceUniqueName,
|
||||||
|
JsonSerializer.Deserialize<FlattenedConfiguration>(configJson),
|
||||||
|
storage, compilationService, sharedScriptLibrary, streamManager, options, logger,
|
||||||
|
dclManager, healthCollector, serviceProvider, staticOverrideFlushInterval,
|
||||||
|
staticStateResetGate)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the instance actor from an ALREADY-PARSED flattened configuration (review
|
||||||
|
/// 2026-10-21 01/SR-P5). The Deployment Manager parses each config once — the deploy compile
|
||||||
|
/// warm, the gate, the DCL-connection ensure and this actor all used to deserialize the same
|
||||||
|
/// JSON separately — and hands the object over. The configuration is read-only here, so
|
||||||
|
/// sharing the instance the Deployment Manager parsed is safe. Parameters are as for the
|
||||||
|
/// JSON overload.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="instanceUniqueName">System-wide unique name identifying this instance.</param>
|
||||||
|
/// <param name="configuration">The parsed flattened configuration (null = empty instance).</param>
|
||||||
|
/// <param name="storage">Site storage service for loading and persisting static overrides.</param>
|
||||||
|
/// <param name="compilationService">Service used to compile instance scripts.</param>
|
||||||
|
/// <param name="sharedScriptLibrary">Library of shared scripts available to instance scripts.</param>
|
||||||
|
/// <param name="streamManager">Optional site stream manager for publishing attribute/alarm changes.</param>
|
||||||
|
/// <param name="options">Site runtime configuration options.</param>
|
||||||
|
/// <param name="logger">Logger for this actor.</param>
|
||||||
|
/// <param name="dclManager">Optional Data Connection Layer manager actor reference.</param>
|
||||||
|
/// <param name="healthCollector">Optional health collector for reporting metrics.</param>
|
||||||
|
/// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
|
||||||
|
/// <param name="staticOverrideFlushInterval">Coalescing window for buffered static-override writes.</param>
|
||||||
|
/// <param name="staticStateResetGate">See the JSON overload.</param>
|
||||||
|
public InstanceActor(
|
||||||
|
string instanceUniqueName,
|
||||||
|
FlattenedConfiguration? configuration,
|
||||||
|
SiteStorageService storage,
|
||||||
|
ScriptCompilationService compilationService,
|
||||||
|
SharedScriptLibrary sharedScriptLibrary,
|
||||||
|
SiteStreamManager? streamManager,
|
||||||
|
SiteRuntimeOptions options,
|
||||||
|
ILogger logger,
|
||||||
|
IActorRef? dclManager = null,
|
||||||
|
ISiteHealthCollector? healthCollector = null,
|
||||||
|
IServiceProvider? serviceProvider = null,
|
||||||
|
TimeSpan? staticOverrideFlushInterval = null,
|
||||||
|
Task? staticStateResetGate = null)
|
||||||
{
|
{
|
||||||
_instanceUniqueName = instanceUniqueName;
|
_instanceUniqueName = instanceUniqueName;
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
@@ -277,8 +321,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
_staticOverrideFlushInterval = staticOverrideFlushInterval ?? TimeSpan.FromMilliseconds(100);
|
_staticOverrideFlushInterval = staticOverrideFlushInterval ?? TimeSpan.FromMilliseconds(100);
|
||||||
_staticStateResetGate = staticStateResetGate;
|
_staticStateResetGate = staticStateResetGate;
|
||||||
|
|
||||||
// Deserialize the flattened configuration
|
_configuration = configuration;
|
||||||
_configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
|
||||||
|
|
||||||
// Load default attribute values from the flattened configuration
|
// Load default attribute values from the flattened configuration
|
||||||
// Data-sourced attributes start with Uncertain quality until the first DCL value arrives.
|
// Data-sourced attributes start with Uncertain quality until the first DCL value arrives.
|
||||||
|
|||||||
@@ -58,19 +58,54 @@ public sealed class DeployCompileValidator
|
|||||||
/// <returns>A list of compile-error messages; empty if the configuration is clean.</returns>
|
/// <returns>A list of compile-error messages; empty if the configuration is clean.</returns>
|
||||||
public IReadOnlyList<string> Validate(string configJson, string? instanceName = null)
|
public IReadOnlyList<string> Validate(string configJson, string? instanceName = null)
|
||||||
{
|
{
|
||||||
FlattenedConfiguration? config;
|
if (!TryParse(configJson, out var config, out var parseError))
|
||||||
|
return [parseError!];
|
||||||
|
|
||||||
|
return Validate(config!, instanceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deserializes a flattened-configuration JSON exactly as <see cref="Validate(string, string?)"/>
|
||||||
|
/// does, so a caller that needs the parsed object as well (the Deployment Manager, which hands
|
||||||
|
/// it on to the Instance Actor — review 2026-10-21 01/SR-P5) parses once instead of once per
|
||||||
|
/// consumer. The error text is the one <see cref="Validate(string, string?)"/> reports.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="configJson">The flattened-configuration JSON.</param>
|
||||||
|
/// <param name="config">The parsed configuration, or <see langword="null"/> on failure.</param>
|
||||||
|
/// <param name="error">The deserialization error, or <see langword="null"/> on success.</param>
|
||||||
|
/// <returns><see langword="true"/> when the JSON produced a configuration.</returns>
|
||||||
|
public static bool TryParse(string configJson, out FlattenedConfiguration? config, out string? error)
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
config = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
config = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
{
|
{
|
||||||
return [$"Configuration JSON failed to deserialize: {ex.Message}"];
|
config = null;
|
||||||
|
error = $"Configuration JSON failed to deserialize: {ex.Message}";
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config == null)
|
if (config == null)
|
||||||
return ["Configuration JSON failed to deserialize: result was null."];
|
{
|
||||||
|
error = "Configuration JSON failed to deserialize: result was null.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates an already-parsed flattened configuration — the same checks as
|
||||||
|
/// <see cref="Validate(string, string?)"/> without a second deserialization (01/SR-P5).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The parsed flattened configuration.</param>
|
||||||
|
/// <param name="instanceName">See <see cref="Validate(string, string?)"/>.</param>
|
||||||
|
/// <returns>A list of error messages; empty if the configuration is clean.</returns>
|
||||||
|
public IReadOnlyList<string> Validate(FlattenedConfiguration config, string? instanceName = null)
|
||||||
|
{
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
// Runtime names first: they are cheap, and a name Akka refuses would otherwise surface
|
// Runtime names first: they are cheap, and a name Akka refuses would otherwise surface
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Akka.Actor;
|
||||||
|
using Akka.TestKit.Xunit2;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
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.Tests.TestSupport;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review 2026-10-21 01/SR-P5: a deploy's flattened config is deserialized ONCE (in the
|
||||||
|
/// off-thread compile warm) and the parsed object is handed to the gate, the DCL-connection
|
||||||
|
/// ensure and the Instance Actor. These pin the two contracts that refactor must keep: a config
|
||||||
|
/// that does not parse is still a readable gate rejection with nothing applied, and an Instance
|
||||||
|
/// Actor built from the parsed object behaves like one built from the JSON.
|
||||||
|
/// </summary>
|
||||||
|
public class DeploymentManagerParseOnceTests : TestKit, IDisposable
|
||||||
|
{
|
||||||
|
private readonly SiteStorageService _storage;
|
||||||
|
private readonly ScriptCompilationService _compilationService;
|
||||||
|
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
||||||
|
private readonly TestLocalDb _localDb;
|
||||||
|
|
||||||
|
/// <summary>Creates a temporary LocalDb-backed site store per test.</summary>
|
||||||
|
public DeploymentManagerParseOnceTests()
|
||||||
|
{
|
||||||
|
_localDb = TestLocalDb.CreateTemp("dm-parse-once");
|
||||||
|
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
||||||
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
||||||
|
_compilationService = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
|
||||||
|
_sharedScriptLibrary = new SharedScriptLibrary(
|
||||||
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
void IDisposable.Dispose()
|
||||||
|
{
|
||||||
|
Shutdown();
|
||||||
|
var path = _localDb.Path;
|
||||||
|
_localDb.Dispose();
|
||||||
|
TestLocalDb.DeleteFiles(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActorRef CreateBootedDeploymentManager()
|
||||||
|
{
|
||||||
|
var health = new DeploymentManagerStartupProbe();
|
||||||
|
var dm = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
||||||
|
_storage, _compilationService, _sharedScriptLibrary, null,
|
||||||
|
new SiteRuntimeOptions { InstanceSweepIntervalSeconds = 0 },
|
||||||
|
NullLogger<DeploymentManagerActor>.Instance,
|
||||||
|
dclManager: null, healthCollector: health, serviceProvider: null,
|
||||||
|
loggerFactory: null, configFetcher: null)));
|
||||||
|
AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25));
|
||||||
|
return dm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Malformed JSON is rejected by the gate with the deserialization error; nothing is persisted.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Deploy_MalformedConfig_IsRejectedWithTheParseError()
|
||||||
|
{
|
||||||
|
var dm = CreateBootedDeploymentManager();
|
||||||
|
|
||||||
|
dm.Tell(new DeployInstanceCommand("dep-1", "Bad1", "r1", "{not json", "tester", DateTimeOffset.UtcNow));
|
||||||
|
var response = ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
|
Assert.Equal(DeploymentStatus.Failed, response.Status);
|
||||||
|
Assert.Contains("failed to deserialize", response.ErrorMessage);
|
||||||
|
Assert.Null(await _storage.GetDeployedConfigAsync("Bad1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A deploy built from the parsed object serves its configured attributes.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task Deploy_ParsedOncePath_CreatesAWorkingInstance()
|
||||||
|
{
|
||||||
|
var dm = CreateBootedDeploymentManager();
|
||||||
|
var json = JsonSerializer.Serialize(new FlattenedConfiguration
|
||||||
|
{
|
||||||
|
InstanceUniqueName = "Good1",
|
||||||
|
Attributes = [new ResolvedAttribute { CanonicalName = "A", Value = "7", DataType = "Int32" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
dm.Tell(new DeployInstanceCommand("dep-2", "Good1", "r1", json, "tester", DateTimeOffset.UtcNow));
|
||||||
|
Assert.Equal(DeploymentStatus.Success, ExpectMsg<DeploymentStatusResponse>(TimeSpan.FromSeconds(15)).Status);
|
||||||
|
|
||||||
|
var instance = await Sys.ActorSelection(dm.Path / "Good1").ResolveOne(TimeSpan.FromSeconds(5));
|
||||||
|
instance.Tell(new GetAttributeRequest("g", "Good1", "A", DateTimeOffset.UtcNow));
|
||||||
|
Assert.Equal("7", ExpectMsg<GetAttributeResponse>().Value?.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user