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:
Joseph Doherty
2026-10-22 06:47:18 -04:00
parent 960c35060c
commit dde22a3dd1
5 changed files with 296 additions and 40 deletions
@@ -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