namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// /// Pure diff between the currently-running driver children (keyed by /// DriverInstance.DriverInstanceId) and the target spec list from a freshly-applied /// deployment artifact. The DriverHostActor consumes the three lists and calls /// spawn / ApplyDelta / stop on its child actors accordingly. /// /// Specs with no current child — create a new actor. /// /// In-place config deltas. Always empty since #516 — a DriverConfig change is now a /// stop + respawn so the factory is the single parse authority. Retained because /// DriverInstanceActor still handles ApplyDelta on its own config-adoption path /// (a config arriving mid-connect), and removing the list would hide that seam. /// /// DriverInstanceIds currently running but missing from the new artifact, or now disabled. public sealed record DriverSpawnPlan( IReadOnlyList ToSpawn, IReadOnlyList ToApplyDelta, IReadOnlyList ToStop); public static class DriverSpawnPlanner { /// /// Compute the spawn/delta/stop sets. Disabled entries in are /// treated as "not desired here": if a child exists for the id it goes into ToStop, /// otherwise the row is dropped entirely (no spawn for a disabled driver). /// /// The currently running driver children keyed by ID. /// The target driver instances from the deployment artifact. /// The computed with spawn, apply-delta, and stop sets. public static DriverSpawnPlan Compute( IReadOnlyDictionary current, IReadOnlyList target) { var toSpawn = new List(); var toDelta = new List(); var toStop = new List(); var targetById = new Dictionary(StringComparer.Ordinal); foreach (var spec in target) targetById[spec.DriverInstanceId] = spec; foreach (var (id, snap) in current) { if (!targetById.TryGetValue(id, out var spec) || !spec.Enabled) { toStop.Add(id); continue; } // ANY of the three config surfaces changing forces a stop + respawn, so the FACTORY is the // single parse authority for everything a driver is built from. // // • DriverType — factory-bound, can't be reinitialized in place. // • ResilienceConfig — the CapabilityInvoker (and its resolved options) binds to the child // actor at spawn time; the factory invalidates the stale cached pipelines on respawn. // • DriverConfig — was an in-place ApplyDelta until #516. It is now a respawn. // // #516: the in-place delta silently DISCARDED the edit on five drivers (Modbus, FOCAS, // OpcUaClient, AbLegacy, Sql), whose InitializeAsync served the options captured by their // constructor and never looked at the driverConfigJson they were handed. The deployment still // sealed green. Those five now re-parse (belt), and this respawn is the braces: several // drivers build MORE than options from config — Sql derives its dialect + connection string // and FOCAS its client-factory backend, both injected at construction — so an in-place // re-parse of options ALONE would apply a new tag set against an old connection. Only a // respawn rebuilds all of it. // // The accepted cost is a reconnect on every DriverConfig edit, replacing the prior // no-reconnect in-place path. That is deliberate: a correct reconnect beats a silent no-op. if (!string.Equals(snap.DriverType, spec.DriverType, StringComparison.Ordinal) || !string.Equals(snap.ResilienceConfig, spec.ResilienceConfig, StringComparison.Ordinal) || !string.Equals(snap.LastConfigJson, spec.DriverConfig, StringComparison.Ordinal)) { toStop.Add(id); toSpawn.Add(spec); continue; } } foreach (var (id, spec) in targetById) { if (!spec.Enabled) continue; if (current.ContainsKey(id)) continue; toSpawn.Add(spec); } return new DriverSpawnPlan(toSpawn, toDelta, toStop); } } /// Snapshot of one running driver child as the host sees it. Used as the diff input. /// The driver type name the child was spawned for. /// The DriverConfig JSON the child currently serves. /// The per-instance ResilienceConfig JSON the child's invoker was built with (null = tier defaults). public sealed record DriverChildSnapshot(string DriverType, string LastConfigJson, string? ResilienceConfig = null);