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).
|
||||
|
||||
> **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`.
|
||||
|
||||
### 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.ScriptExecution;
|
||||
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.SiteEventLogging;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
||||
@@ -985,9 +986,16 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
var configJson = command.FlattenedConfigurationJson;
|
||||
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
|
||||
{
|
||||
validator.Validate(configJson);
|
||||
validator.Validate(parsed!);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -996,7 +1004,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
command.InstanceUniqueName);
|
||||
}
|
||||
|
||||
return new DeployCompileWarmed(command);
|
||||
return new DeployCompileWarmed(command, parsed, null);
|
||||
}).PipeTo(Self);
|
||||
}
|
||||
|
||||
@@ -1019,7 +1027,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
|
||||
_deployWarms.Remove(instanceName);
|
||||
|
||||
RunDeployGateAndProceed(warming.Command, warming.ReplyTo);
|
||||
RunDeployGateAndProceed(warming.Command, warming.ReplyTo, msg.Parsed, msg.ParseError);
|
||||
|
||||
foreach (var queued in warming.Buffered)
|
||||
DispatchBufferedCommand(queued);
|
||||
@@ -1072,7 +1080,13 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// only from <see cref="HandleDeployCompileWarmed"/>, i.e. after the same compile has
|
||||
/// been warmed off-thread.
|
||||
/// </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
|
||||
// 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
|
||||
// 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.
|
||||
var errors = _deployCompileValidator.Validate(
|
||||
command.FlattenedConfigurationJson, command.InstanceUniqueName);
|
||||
var errors = parsed == null
|
||||
? [parseError ?? "Configuration JSON failed to deserialize."]
|
||||
: _deployCompileValidator.Validate(parsed, command.InstanceUniqueName);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
var instanceName = command.InstanceUniqueName;
|
||||
@@ -1109,14 +1124,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
return;
|
||||
}
|
||||
|
||||
ProceedWithDeploy(command, replyTo);
|
||||
ProceedWithDeploy(command, replyTo, parsed!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core deploy application (redeploy-supersede + fresh apply), reached only
|
||||
/// after the synchronous compile gate in <see cref="HandleDeploy"/> passes.
|
||||
/// </summary>
|
||||
private void ProceedWithDeploy(DeployInstanceCommand command, IActorRef replyTo)
|
||||
private void ProceedWithDeploy(
|
||||
DeployInstanceCommand command, IActorRef replyTo, FlattenedConfiguration parsed)
|
||||
{
|
||||
var instanceName = command.InstanceUniqueName;
|
||||
_logger.LogInformation(
|
||||
@@ -1129,7 +1145,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
if (_instanceActors.TryGetValue(instanceName, out var existing))
|
||||
{
|
||||
_instanceActors.Remove(instanceName);
|
||||
_pendingRedeploys[existing] = new PendingRedeploy(command, replyTo);
|
||||
_pendingRedeploys[existing] = new PendingRedeploy(command, replyTo, parsed);
|
||||
_terminatingActorsByName[instanceName] = existing;
|
||||
Context.Watch(existing);
|
||||
_redeployStopQueue.Enqueue(existing);
|
||||
@@ -1157,12 +1173,12 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
$"superseded by newer deployment {command.DeploymentId} before predecessor finished terminating",
|
||||
DateTimeOffset.UtcNow));
|
||||
}
|
||||
_pendingRedeploys[terminatingRef] = new PendingRedeploy(command, replyTo);
|
||||
_pendingRedeploys[terminatingRef] = new PendingRedeploy(command, replyTo, parsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -1224,7 +1240,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
_terminatingActorsByName.Remove(pending.Command.InstanceUniqueName);
|
||||
if (flush.Error is null)
|
||||
ApplyDeployment(pending.Command, pending.OriginalSender, isRedeploy: true);
|
||||
ApplyDeployment(pending.Command, pending.OriginalSender, isRedeploy: true, pending.Parsed);
|
||||
else
|
||||
pending.OriginalSender.Tell(new DeploymentStatusResponse(pending.Command.DeploymentId,
|
||||
pending.Command.InstanceUniqueName, DeploymentStatus.Failed,
|
||||
@@ -1381,7 +1397,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// incorrect. The reply is sent from <see cref="HandleDeployPersistenceResult"/> once
|
||||
/// the persistence outcome is known.
|
||||
/// </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;
|
||||
|
||||
@@ -1413,7 +1430,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
// connections this config's data-sourced attributes subscribe through (register row 85).
|
||||
if (!CreateInstanceActor(
|
||||
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
|
||||
// never escape to the supervisor and restart the singleton.
|
||||
@@ -2888,10 +2905,32 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
{
|
||||
if (_dclManager == null) return;
|
||||
|
||||
FlattenedConfiguration? config;
|
||||
try
|
||||
{
|
||||
var config = System.Text.Json.JsonSerializer.Deserialize<Commons.Types.Flattening.FlattenedConfiguration>(configJson);
|
||||
if (config?.Connections == null) return;
|
||||
config = System.Text.Json.JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -2928,7 +2967,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
}
|
||||
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
|
||||
/// nothing is being reset and the load runs immediately as before.
|
||||
/// </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>
|
||||
/// <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.
|
||||
/// </returns>
|
||||
internal bool CreateInstanceActor(
|
||||
string instanceName, string configJson, string revisionHash, out string? error,
|
||||
Task? staticStateResetGate = null)
|
||||
Task? staticStateResetGate = null, FlattenedConfiguration? parsed = 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
|
||||
// actors its data-sourced attributes subscribe through, so ensuring them belongs in the
|
||||
// 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
|
||||
// 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.
|
||||
EnsureDclConnections(configJson);
|
||||
if (parsed != null)
|
||||
EnsureDclConnections(parsed);
|
||||
else
|
||||
EnsureDclConnections(configJson);
|
||||
|
||||
if (_instanceActors.ContainsKey(instanceName))
|
||||
{
|
||||
@@ -3787,20 +3841,36 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
|
||||
// Reuse the shared, host-configured logger factory
|
||||
// instead of allocating (and leaking) a fresh LoggerFactory per instance.
|
||||
var props = Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
configJson,
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
_streamManager,
|
||||
_options,
|
||||
_loggerFactory.CreateLogger<InstanceActor>(),
|
||||
_dclManager,
|
||||
_healthCollector,
|
||||
_serviceProvider,
|
||||
null,
|
||||
staticStateResetGate));
|
||||
var instanceLogger = _loggerFactory.CreateLogger<InstanceActor>();
|
||||
var props = parsed != null
|
||||
? Props.Create(() => new InstanceActor(
|
||||
instanceName,
|
||||
parsed,
|
||||
_storage,
|
||||
_compilationService,
|
||||
_sharedScriptLibrary,
|
||||
_streamManager,
|
||||
_options,
|
||||
instanceLogger,
|
||||
_dclManager,
|
||||
_healthCollector,
|
||||
_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
|
||||
// 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
|
||||
/// and dropped.
|
||||
/// </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>
|
||||
/// Piped back to self once a staggered startup batch's compiles have been
|
||||
@@ -4100,7 +4174,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
||||
/// <summary>
|
||||
/// A redeployment command buffered until the previous Instance Actor terminates.
|
||||
/// </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>
|
||||
/// Join state for a deployment awaiting confirmation. Deployment <c>Success</c> is
|
||||
|
||||
@@ -262,6 +262,50 @@ public class InstanceActor : ReceiveActor
|
||||
IServiceProvider? serviceProvider = null,
|
||||
TimeSpan? staticOverrideFlushInterval = 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;
|
||||
_storage = storage;
|
||||
@@ -277,8 +321,7 @@ public class InstanceActor : ReceiveActor
|
||||
_staticOverrideFlushInterval = staticOverrideFlushInterval ?? TimeSpan.FromMilliseconds(100);
|
||||
_staticStateResetGate = staticStateResetGate;
|
||||
|
||||
// Deserialize the flattened configuration
|
||||
_configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
||||
_configuration = configuration;
|
||||
|
||||
// Load default attribute values from the flattened configuration
|
||||
// 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>
|
||||
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
|
||||
{
|
||||
config = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
||||
}
|
||||
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)
|
||||
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>();
|
||||
|
||||
// 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