diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs deleted file mode 100644 index 585ed19..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs +++ /dev/null @@ -1,19 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -/// -/// Host-supplied callbacks invoked as the applier walks the diff. Callbacks are idempotent on -/// retry (the applier may re-invoke with the same inputs if a later stage fails — nodes -/// register-applied to the central DB only after success). Order: namespace → driver → device → -/// equipment → poll group → tag, with Removed before Added/Modified. -/// -public sealed class ApplyCallbacks -{ - public Func, CancellationToken, Task>? OnNamespace { get; init; } - public Func, CancellationToken, Task>? OnDriver { get; init; } - public Func, CancellationToken, Task>? OnDevice { get; init; } - public Func, CancellationToken, Task>? OnEquipment { get; init; } - public Func, CancellationToken, Task>? OnPollGroup { get; init; } - public Func, CancellationToken, Task>? OnTag { get; init; } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs deleted file mode 100644 index 56f3618..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -public enum ChangeKind -{ - Added, - Removed, - Modified, -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs deleted file mode 100644 index 982b026..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs +++ /dev/null @@ -1,58 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Validation; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -public sealed class GenerationApplier(ApplyCallbacks callbacks) : IGenerationApplier -{ - public async Task ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct) - { - var diff = GenerationDiffer.Compute(from, to); - var errors = new List(); - - // Removed first, then Added/Modified — prevents FK dangling while cascades settle. - await ApplyPass(diff.Tags, ChangeKind.Removed, callbacks.OnTag, errors, ct); - await ApplyPass(diff.PollGroups, ChangeKind.Removed, callbacks.OnPollGroup, errors, ct); - await ApplyPass(diff.Equipment, ChangeKind.Removed, callbacks.OnEquipment, errors, ct); - await ApplyPass(diff.Devices, ChangeKind.Removed, callbacks.OnDevice, errors, ct); - await ApplyPass(diff.Drivers, ChangeKind.Removed, callbacks.OnDriver, errors, ct); - await ApplyPass(diff.Namespaces, ChangeKind.Removed, callbacks.OnNamespace, errors, ct); - - foreach (var kind in new[] { ChangeKind.Added, ChangeKind.Modified }) - { - // Honour cancellation between passes — a caller can abort the apply between Removed - // and Added phases even if individual callbacks don't observe the token themselves - // (Configuration-007). - ct.ThrowIfCancellationRequested(); - await ApplyPass(diff.Namespaces, kind, callbacks.OnNamespace, errors, ct); - await ApplyPass(diff.Drivers, kind, callbacks.OnDriver, errors, ct); - await ApplyPass(diff.Devices, kind, callbacks.OnDevice, errors, ct); - await ApplyPass(diff.Equipment, kind, callbacks.OnEquipment, errors, ct); - await ApplyPass(diff.PollGroups, kind, callbacks.OnPollGroup, errors, ct); - await ApplyPass(diff.Tags, kind, callbacks.OnTag, errors, ct); - } - - return errors.Count == 0 ? ApplyResult.Ok(diff) : ApplyResult.Fail(diff, errors); - } - - private static async Task ApplyPass( - IReadOnlyList> changes, - ChangeKind kind, - Func, CancellationToken, Task>? callback, - List errors, - CancellationToken ct) - { - if (callback is null) return; - - foreach (var change in changes.Where(c => c.Kind == kind)) - { - try { await callback(change, ct); } - // Configuration-007: cancellation must propagate, not be silently recorded as an - // entity error. Distinguish caller cancellation (token signalled) from any - // OperationCanceledException raised independently of the caller's token, which we - // still want to surface as an entity error so a single misbehaving callback does - // not crash the entire apply. - catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } - catch (Exception ex) { errors.Add($"{typeof(T).Name} {change.Kind} '{change.LogicalId}': {ex.Message}"); } - } - } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs deleted file mode 100644 index 6813f62..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs +++ /dev/null @@ -1,70 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Validation; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -/// -/// Per-entity diff computed locally on the node. The enumerable order matches the dependency -/// order expected by : namespace → driver → device → equipment → -/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades -/// settle before new rows appear. -/// -public sealed record GenerationDiff( - IReadOnlyList> Namespaces, - IReadOnlyList> Drivers, - IReadOnlyList> Devices, - IReadOnlyList> Equipment, - IReadOnlyList> PollGroups, - IReadOnlyList> Tags); - -public sealed record EntityChange(ChangeKind Kind, string LogicalId, T? From, T? To); - -public static class GenerationDiffer -{ - public static GenerationDiff Compute(DraftSnapshot? from, DraftSnapshot to) - { - from ??= new DraftSnapshot { GenerationId = 0, ClusterId = to.ClusterId }; - - return new GenerationDiff( - Namespaces: DiffById(from.Namespaces, to.Namespaces, x => x.NamespaceId, - (a, b) => (a.ClusterId, a.NamespaceUri, a.Kind, a.Enabled, a.Notes) - == (b.ClusterId, b.NamespaceUri, b.Kind, b.Enabled, b.Notes)), - Drivers: DiffById(from.DriverInstances, to.DriverInstances, x => x.DriverInstanceId, - (a, b) => (a.ClusterId, a.NamespaceId, a.Name, a.DriverType, a.Enabled, a.DriverConfig) - == (b.ClusterId, b.NamespaceId, b.Name, b.DriverType, b.Enabled, b.DriverConfig)), - Devices: DiffById(from.Devices, to.Devices, x => x.DeviceId, - (a, b) => (a.DriverInstanceId, a.Name, a.Enabled, a.DeviceConfig) - == (b.DriverInstanceId, b.Name, b.Enabled, b.DeviceConfig)), - Equipment: DiffById(from.Equipment, to.Equipment, x => x.EquipmentId, - (a, b) => (a.EquipmentUuid, a.DriverInstanceId, a.UnsLineId, a.Name, a.MachineCode, a.ZTag, a.SAPID, a.Enabled) - == (b.EquipmentUuid, b.DriverInstanceId, b.UnsLineId, b.Name, b.MachineCode, b.ZTag, b.SAPID, b.Enabled)), - PollGroups: DiffById(from.PollGroups, to.PollGroups, x => x.PollGroupId, - (a, b) => (a.DriverInstanceId, a.Name, a.IntervalMs) - == (b.DriverInstanceId, b.Name, b.IntervalMs)), - Tags: DiffById(from.Tags, to.Tags, x => x.TagId, - (a, b) => (a.DriverInstanceId, a.DeviceId, a.EquipmentId, a.PollGroupId, a.FolderPath, a.Name, a.DataType, a.AccessLevel, a.WriteIdempotent, a.TagConfig) - == (b.DriverInstanceId, b.DeviceId, b.EquipmentId, b.PollGroupId, b.FolderPath, b.Name, b.DataType, b.AccessLevel, b.WriteIdempotent, b.TagConfig))); - } - - private static List> DiffById( - IReadOnlyList from, IReadOnlyList to, - Func id, Func equal) - { - var fromById = from.ToDictionary(id); - var toById = to.ToDictionary(id); - var result = new List>(); - - foreach (var (logicalId, src) in fromById.Where(kv => !toById.ContainsKey(kv.Key))) - result.Add(new(ChangeKind.Removed, logicalId, src, default)); - - foreach (var (logicalId, dst) in toById) - { - if (!fromById.TryGetValue(logicalId, out var src)) - result.Add(new(ChangeKind.Added, logicalId, default, dst)); - else if (!equal(src, dst)) - result.Add(new(ChangeKind.Modified, logicalId, src, dst)); - } - - return result; - } -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs deleted file mode 100644 index 257fc4b..0000000 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs +++ /dev/null @@ -1,23 +0,0 @@ -using ZB.MOM.WW.OtOpcUa.Configuration.Validation; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; - -/// -/// Applies a to whatever backing runtime the node owns: the OPC UA -/// address space, driver subscriptions, the local cache, etc. The Core project wires concrete -/// callbacks into this via so the Configuration project stays free -/// of a Core/Server dependency (interface independence per decision #59). -/// -public interface IGenerationApplier -{ - Task ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct); -} - -public sealed record ApplyResult( - bool Succeeded, - GenerationDiff Diff, - IReadOnlyList Errors) -{ - public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []); - public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList errors) => new(false, diff, errors); -} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs index 92d3695..31e325d 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs @@ -25,7 +25,8 @@ public interface IDriver /// /// Apply a config change in place without tearing down the driver process. - /// Used by IGenerationApplier when only this driver's config changed in the new generation. + /// Invoked by the v2 DriverInstanceActor when ApplyDelta reports that only this + /// driver's config changed in the new deployment. /// /// /// Per docs/v2/driver-stability.md §"In-process only (Tier A/B)" — Reinitialize is the diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationApplierTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationApplierTests.cs deleted file mode 100644 index 7219f05..0000000 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationApplierTests.cs +++ /dev/null @@ -1,221 +0,0 @@ -using Shouldly; -using Xunit; -using ZB.MOM.WW.OtOpcUa.Configuration.Apply; -using ZB.MOM.WW.OtOpcUa.Configuration.Entities; -using ZB.MOM.WW.OtOpcUa.Configuration.Enums; -using ZB.MOM.WW.OtOpcUa.Configuration.Validation; - -namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; - -[Trait("Category", "Unit")] -public sealed class GenerationApplierTests -{ - private static DraftSnapshot SnapshotWith( - IReadOnlyList? drivers = null, - IReadOnlyList? equipment = null, - IReadOnlyList? tags = null) => new() - { - GenerationId = 1, ClusterId = "c", - DriverInstances = drivers ?? [], - Equipment = equipment ?? [], - Tags = tags ?? [], - }; - - private static DriverInstance Driver(string id) => - new() { DriverInstanceId = id, ClusterId = "c", NamespaceId = "ns", Name = id, DriverType = "ModbusTcp", DriverConfig = "{}" }; - - private static Equipment Eq(string id, Guid uuid) => - new() { EquipmentUuid = uuid, EquipmentId = id, DriverInstanceId = "d", UnsLineId = "line-a", Name = id, MachineCode = id }; - - private static Tag Tag(string id, string name) => - new() { TagId = id, DriverInstanceId = "d", Name = name, FolderPath = "/a", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" }; - - [Fact] - public void Diff_from_empty_to_one_driver_five_equipment_fifty_tags_is_all_Added() - { - var uuid = (int i) => Guid.Parse($"00000000-0000-0000-0000-{i:000000000000}"); - var equipment = Enumerable.Range(1, 5).Select(i => Eq($"eq-{i}", uuid(i))).ToList(); - var tags = Enumerable.Range(1, 50).Select(i => Tag($"tag-{i}", $"T{i}")).ToList(); - - var diff = GenerationDiffer.Compute(from: null, - to: SnapshotWith(drivers: [Driver("d-1")], equipment: equipment, tags: tags)); - - diff.Drivers.Count.ShouldBe(1); - diff.Drivers.ShouldAllBe(c => c.Kind == ChangeKind.Added); - diff.Equipment.Count.ShouldBe(5); - diff.Equipment.ShouldAllBe(c => c.Kind == ChangeKind.Added); - diff.Tags.Count.ShouldBe(50); - diff.Tags.ShouldAllBe(c => c.Kind == ChangeKind.Added); - } - - [Fact] - public void Diff_flags_single_tag_name_change_as_Modified_only_for_that_tag() - { - var before = SnapshotWith(tags: [Tag("tag-1", "Old"), Tag("tag-2", "Keep")]); - var after = SnapshotWith(tags: [Tag("tag-1", "New"), Tag("tag-2", "Keep")]); - - var diff = GenerationDiffer.Compute(before, after); - - diff.Tags.Count.ShouldBe(1); - diff.Tags[0].Kind.ShouldBe(ChangeKind.Modified); - diff.Tags[0].LogicalId.ShouldBe("tag-1"); - } - - [Fact] - public void Diff_flags_Removed_equipment_and_its_tags() - { - var uuid1 = Guid.NewGuid(); - var before = SnapshotWith( - equipment: [Eq("eq-1", uuid1), Eq("eq-2", Guid.NewGuid())], - tags: [Tag("tag-1", "A"), Tag("tag-2", "B")]); - var after = SnapshotWith( - equipment: [Eq("eq-2", before.Equipment[1].EquipmentUuid)], - tags: [Tag("tag-2", "B")]); - - var diff = GenerationDiffer.Compute(before, after); - - diff.Equipment.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "eq-1"); - diff.Tags.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "tag-1"); - } - - [Fact] - public async Task Apply_dispatches_callbacks_in_dependency_order_and_survives_idempotent_retry() - { - var callLog = new List(); - var applier = new GenerationApplier(new ApplyCallbacks - { - OnDriver = (c, _) => { callLog.Add($"drv:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, - OnEquipment = (c, _) => { callLog.Add($"eq:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, - OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, - }); - - var to = SnapshotWith( - drivers: [Driver("d-1")], - equipment: [Eq("eq-1", Guid.NewGuid())], - tags: [Tag("tag-1", "A")]); - - var result1 = await applier.ApplyAsync(from: null, to, CancellationToken.None); - result1.Succeeded.ShouldBeTrue(); - - // Driver Added must come before Equipment Added must come before Tag Added - var drvIdx = callLog.FindIndex(s => s.StartsWith("drv:Added")); - var eqIdx = callLog.FindIndex(s => s.StartsWith("eq:Added")); - var tagIdx = callLog.FindIndex(s => s.StartsWith("tag:Added")); - drvIdx.ShouldBeLessThan(eqIdx); - eqIdx.ShouldBeLessThan(tagIdx); - - // Idempotent retry: re-applying the same diff must not blow up - var countBefore = callLog.Count; - var result2 = await applier.ApplyAsync(from: null, to, CancellationToken.None); - result2.Succeeded.ShouldBeTrue(); - callLog.Count.ShouldBe(countBefore * 2); - } - - [Fact] - public async Task Apply_collects_errors_from_failing_callback_without_aborting() - { - var applier = new GenerationApplier(new ApplyCallbacks - { - OnTag = (c, _) => - c.LogicalId == "tag-bad" - ? throw new InvalidOperationException("simulated") - : Task.CompletedTask, - }); - - var to = SnapshotWith(tags: [Tag("tag-ok", "A"), Tag("tag-bad", "B")]); - var result = await applier.ApplyAsync(from: null, to, CancellationToken.None); - - result.Succeeded.ShouldBeFalse(); - result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated")); - } - - // ------------------------------------------------------------------------------------ - // Configuration-011 — pin the documented ordering behaviour: a thrown Removed callback - // records an entity error but the applier still runs the Added/Modified passes (the - // current contract — see GenerationApplier comment about cascades settling). - // ------------------------------------------------------------------------------------ - - [Fact] - public async Task Apply_continues_to_Added_pass_when_a_Removed_callback_throws() - { - var callLog = new List(); - var applier = new GenerationApplier(new ApplyCallbacks - { - OnTag = (c, _) => - { - callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); - if (c.Kind == ChangeKind.Removed) - throw new InvalidOperationException("removed-failed"); - return Task.CompletedTask; - }, - }); - - var from = SnapshotWith(tags: [Tag("tag-old", "X")]); - var to = SnapshotWith(tags: [Tag("tag-new", "Y")]); - - var result = await applier.ApplyAsync(from, to, CancellationToken.None); - - result.Succeeded.ShouldBeFalse(); - result.Errors.ShouldContain(e => e.Contains("tag-old") && e.Contains("removed-failed")); - // The Added pass still runs even though Removed failed. - callLog.ShouldContain("tag:Removed:tag-old"); - callLog.ShouldContain("tag:Added:tag-new"); - } - - // ------------------------------------------------------------------------------------ - // Configuration-007 — ApplyPass must propagate OperationCanceledException rather than - // recording it as an entity error. Cancellation between passes must also halt the apply. - // ------------------------------------------------------------------------------------ - - [Fact] - public async Task Apply_propagates_OperationCanceledException_from_callback_when_token_cancelled() - { - // A callback that observes a cancelled token and throws OperationCanceledException - // must abort the entire apply, not be silently swallowed and recorded as an error. - using var cts = new CancellationTokenSource(); - var applier = new GenerationApplier(new ApplyCallbacks - { - OnTag = (c, ct) => - { - cts.Cancel(); - ct.ThrowIfCancellationRequested(); - return Task.CompletedTask; - }, - }); - - var to = SnapshotWith(tags: [Tag("tag-1", "A")]); - - await Should.ThrowAsync(async () => - await applier.ApplyAsync(from: null, to, cts.Token)); - } - - [Fact] - public async Task Apply_stops_between_passes_when_cancellation_requested() - { - // After a Removed pass completes, the applier should observe cancellation before - // running the Added/Modified passes — not silently keep walking. - var callLog = new List(); - using var cts = new CancellationTokenSource(); - var applier = new GenerationApplier(new ApplyCallbacks - { - OnTag = (c, _) => - { - callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); - // Cancel after the Removed pass finishes — before the Added pass runs. - if (c.Kind == ChangeKind.Removed) cts.Cancel(); - return Task.CompletedTask; - }, - }); - - // `from` has tag-1, `to` has tag-2 — produces one Removed + one Added. - var from = SnapshotWith(tags: [Tag("tag-1", "A")]); - var to = SnapshotWith(tags: [Tag("tag-2", "B")]); - - await Should.ThrowAsync(async () => - await applier.ApplyAsync(from, to, cts.Token)); - - callLog.ShouldContain("tag:Removed:tag-1"); - callLog.ShouldNotContain("tag:Added:tag-2", - "Added pass must not run after cancellation observed between passes"); - } -}