From dde22a3dd1ccc0cc3355cd9253077fb8961b5a8e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:47:18 -0400 Subject: [PATCH] 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. --- docs/requirements/Component-SiteRuntime.md | 2 + .../Actors/DeploymentManagerActor.cs | 148 +++++++++++++----- .../Actors/InstanceActor.cs | 47 +++++- .../Deployment/DeployCompileValidator.cs | 41 ++++- .../Actors/DeploymentManagerParseOnceTests.cs | 98 ++++++++++++ 5 files changed, 296 insertions(+), 40 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerParseOnceTests.cs diff --git a/docs/requirements/Component-SiteRuntime.md b/docs/requirements/Component-SiteRuntime.md index 4fc5a8a9..7850578f 100644 --- a/docs/requirements/Component-SiteRuntime.md +++ b/docs/requirements/Component-SiteRuntime.md @@ -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` 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 diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index 72eb5359..49442e26 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -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 , i.e. after the same compile has /// been warmed off-thread. /// - private void RunDeployGateAndProceed(DeployInstanceCommand command, IActorRef replyTo) + /// The deploy command being gated. + /// The deployer to answer. + /// The config parsed once by the warm (01/SR-P5), or null on a parse failure. + /// The warm's deserialization error, when is null. + 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!); } /// /// Core deploy application (redeploy-supersede + fresh apply), reached only /// after the synchronous compile gate in passes. /// - 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 once /// the persistence outcome is known. /// - 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(configJson); - if (config?.Connections == null) return; + config = System.Text.Json.JsonSerializer.Deserialize(configJson); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse flattened config for DCL connections"); + return; + } + + if (config != null) + EnsureDclConnections(config); + } + + /// + /// over an already-parsed configuration + /// (review 2026-10-21 01/SR-P5) — the form every creation path now uses. + /// + 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 /// on every non-deploy start (boot batch, enable, self-heal), where /// nothing is being reset and the load runs immediately as before. /// + /// + /// already deserialized by the caller (the deploy warm or the + /// boot-batch warm — review 2026-10-21 01/SR-P5), or to parse it here, + /// ONCE, for both the DCL-connection ensure and the Instance Actor. + /// /// /// 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. /// 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(), - _dclManager, - _healthCollector, - _serviceProvider, - null, - staticStateResetGate)); + var instanceLogger = _loggerFactory.CreateLogger(); + 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. /// - internal sealed record DeployCompileWarmed(DeployInstanceCommand Command); + /// The exact command instance the warm ran for. + /// The config parsed once off-thread (01/SR-P5); null when it did not parse. + /// Why it did not parse, when is null. + internal sealed record DeployCompileWarmed( + DeployInstanceCommand Command, FlattenedConfiguration? Parsed = null, string? ParseError = null); /// /// Piped back to self once a staggered startup batch's compiles have been @@ -4100,7 +4174,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers /// /// A redeployment command buffered until the previous Instance Actor terminates. /// - internal record PendingRedeploy(DeployInstanceCommand Command, IActorRef OriginalSender); + /// The buffered deploy. + /// The deployer to answer. + /// The config the gate already parsed (01/SR-P5), reused on apply. + internal record PendingRedeploy( + DeployInstanceCommand Command, IActorRef OriginalSender, FlattenedConfiguration? Parsed = null); /// /// Join state for a deployment awaiting confirmation. Deployment Success is diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs index 67633e6c..c298ca0c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs @@ -262,6 +262,50 @@ public class InstanceActor : ReceiveActor IServiceProvider? serviceProvider = null, TimeSpan? staticOverrideFlushInterval = null, Task? staticStateResetGate = null) + : this( + instanceUniqueName, + JsonSerializer.Deserialize(configJson), + storage, compilationService, sharedScriptLibrary, streamManager, options, logger, + dclManager, healthCollector, serviceProvider, staticOverrideFlushInterval, + staticStateResetGate) + { + } + + /// + /// 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. + /// + /// System-wide unique name identifying this instance. + /// The parsed flattened configuration (null = empty instance). + /// Site storage service for loading and persisting static overrides. + /// Service used to compile instance scripts. + /// Library of shared scripts available to instance scripts. + /// Optional site stream manager for publishing attribute/alarm changes. + /// Site runtime configuration options. + /// Logger for this actor. + /// Optional Data Connection Layer manager actor reference. + /// Optional health collector for reporting metrics. + /// Optional DI service provider for script execution services. + /// Coalescing window for buffered static-override writes. + /// See the JSON overload. + 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(configJson); + _configuration = configuration; // Load default attribute values from the flattened configuration // Data-sourced attributes start with Uncertain quality until the first DCL value arrives. diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs index e4fde7d8..b6ffdd1e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Deployment/DeployCompileValidator.cs @@ -58,19 +58,54 @@ public sealed class DeployCompileValidator /// A list of compile-error messages; empty if the configuration is clean. public IReadOnlyList Validate(string configJson, string? instanceName = null) { - FlattenedConfiguration? config; + if (!TryParse(configJson, out var config, out var parseError)) + return [parseError!]; + + return Validate(config!, instanceName); + } + + /// + /// Deserializes a flattened-configuration JSON exactly as + /// 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 reports. + /// + /// The flattened-configuration JSON. + /// The parsed configuration, or on failure. + /// The deserialization error, or on success. + /// when the JSON produced a configuration. + public static bool TryParse(string configJson, out FlattenedConfiguration? config, out string? error) + { try { config = JsonSerializer.Deserialize(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; + } + + /// + /// Validates an already-parsed flattened configuration — the same checks as + /// without a second deserialization (01/SR-P5). + /// + /// The parsed flattened configuration. + /// See . + /// A list of error messages; empty if the configuration is clean. + public IReadOnlyList Validate(FlattenedConfiguration config, string? instanceName = null) + { var errors = new List(); // Runtime names first: they are cheap, and a name Akka refuses would otherwise surface diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerParseOnceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerParseOnceTests.cs new file mode 100644 index 00000000..e19c3e03 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerParseOnceTests.cs @@ -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; + +/// +/// 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. +/// +public class DeploymentManagerParseOnceTests : TestKit, IDisposable +{ + private readonly SiteStorageService _storage; + private readonly ScriptCompilationService _compilationService; + private readonly SharedScriptLibrary _sharedScriptLibrary; + private readonly TestLocalDb _localDb; + + /// Creates a temporary LocalDb-backed site store per test. + public DeploymentManagerParseOnceTests() + { + _localDb = TestLocalDb.CreateTemp("dm-parse-once"); + _storage = new SiteStorageService(_localDb.Db, NullLogger.Instance); + _storage.InitializeAsync().GetAwaiter().GetResult(); + _compilationService = new ScriptCompilationService(NullLogger.Instance); + _sharedScriptLibrary = new SharedScriptLibrary( + _compilationService, NullLogger.Instance); + } + + /// + 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.Instance, + dclManager: null, healthCollector: health, serviceProvider: null, + loggerFactory: null, configFetcher: null))); + AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20), TimeSpan.FromMilliseconds(25)); + return dm; + } + + /// Malformed JSON is rejected by the gate with the deserialization error; nothing is persisted. + /// A task that represents the asynchronous operation. + [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(TimeSpan.FromSeconds(15)); + + Assert.Equal(DeploymentStatus.Failed, response.Status); + Assert.Contains("failed to deserialize", response.ErrorMessage); + Assert.Null(await _storage.GetDeployedConfigAsync("Bad1")); + } + + /// A deploy built from the parsed object serves its configured attributes. + /// A task that represents the asynchronous operation. + [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(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().Value?.ToString()); + } +}