refactor(configdb): delete v1 Apply pipeline (replaced by AdminOperationsActor)

Phase 1c of the v2 entity-model rewrite. Deletes the draft/publish lifecycle
machinery that v2 replaces with AdminOperationsActor + ConfigComposer +
DriverInstanceActor.ApplyDelta.

Deleted (6 files):

  src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Apply/
    IGenerationApplier.cs   — interface for the apply pipeline
    GenerationApplier.cs    — the v1 applier coordinating per-driver hook-back
    GenerationDiff.cs       — typed wrapper over the sp_ComputeGenerationDiff
                              SQL output
    ApplyCallbacks.cs       — per-driver hook surface invoked by the applier
    ChangeKind.cs           — enum {Added, Modified, Removed, Unchanged}

  tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationApplierTests.cs

The empty Apply/ directory is removed.

Kept (repurposed in Task 39 for stale-config fallback):

  src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs
  src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ResilientConfigReader.cs
  tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationSealedCacheTests.cs
  tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ResilientConfigReaderTests.cs

Naming rename (GenerationSealedCache -> DeploymentArtifactCache) deferred
to Task 39 (DriverHostActor stale-config fallback) where the consumer is
written. The type stays available under its v1 name until then.

IDriver.cs doc-comment: replaced the "Used by IGenerationApplier..." sentence
with "Invoked by the v2 DriverInstanceActor when ApplyDelta reports that only
this driver's config changed in the new deployment."

Server/Admin breakage from Task 14b unchanged (70 errors). Configuration +
Core.Tests + Configuration.Tests stay green.

  src/Core/ZB.MOM.WW.OtOpcUa.Configuration  -> 0 errors
  tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests  -> 0 errors
  whole solution  -> 70 errors (all in Server/Admin)
This commit is contained in:
Joseph Doherty
2026-05-26 04:09:17 -04:00
parent 13d3aeab09
commit 1ddf8bb50e
7 changed files with 2 additions and 400 deletions

View File

@@ -1,19 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
/// <summary>
/// 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.
/// </summary>
public sealed class ApplyCallbacks
{
public Func<EntityChange<Namespace>, CancellationToken, Task>? OnNamespace { get; init; }
public Func<EntityChange<DriverInstance>, CancellationToken, Task>? OnDriver { get; init; }
public Func<EntityChange<Device>, CancellationToken, Task>? OnDevice { get; init; }
public Func<EntityChange<Equipment>, CancellationToken, Task>? OnEquipment { get; init; }
public Func<EntityChange<PollGroup>, CancellationToken, Task>? OnPollGroup { get; init; }
public Func<EntityChange<Tag>, CancellationToken, Task>? OnTag { get; init; }
}

View File

@@ -1,8 +0,0 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
public enum ChangeKind
{
Added,
Removed,
Modified,
}

View File

@@ -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<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct)
{
var diff = GenerationDiffer.Compute(from, to);
var errors = new List<string>();
// 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<T>(
IReadOnlyList<EntityChange<T>> changes,
ChangeKind kind,
Func<EntityChange<T>, CancellationToken, Task>? callback,
List<string> 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}"); }
}
}
}

View File

@@ -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;
/// <summary>
/// Per-entity diff computed locally on the node. The enumerable order matches the dependency
/// order expected by <see cref="IGenerationApplier"/>: namespace → driver → device → equipment →
/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades
/// settle before new rows appear.
/// </summary>
public sealed record GenerationDiff(
IReadOnlyList<EntityChange<Namespace>> Namespaces,
IReadOnlyList<EntityChange<DriverInstance>> Drivers,
IReadOnlyList<EntityChange<Device>> Devices,
IReadOnlyList<EntityChange<Equipment>> Equipment,
IReadOnlyList<EntityChange<PollGroup>> PollGroups,
IReadOnlyList<EntityChange<Tag>> Tags);
public sealed record EntityChange<T>(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<EntityChange<T>> DiffById<T>(
IReadOnlyList<T> from, IReadOnlyList<T> to,
Func<T, string> id, Func<T, T, bool> equal)
{
var fromById = from.ToDictionary(id);
var toById = to.ToDictionary(id);
var result = new List<EntityChange<T>>();
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;
}
}

View File

@@ -1,23 +0,0 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
/// <summary>
/// Applies a <see cref="GenerationDiff"/> 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 <see cref="ApplyCallbacks"/> so the Configuration project stays free
/// of a Core/Server dependency (interface independence per decision #59).
/// </summary>
public interface IGenerationApplier
{
Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct);
}
public sealed record ApplyResult(
bool Succeeded,
GenerationDiff Diff,
IReadOnlyList<string> Errors)
{
public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []);
public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList<string> errors) => new(false, diff, errors);
}

View File

@@ -25,7 +25,8 @@ public interface IDriver
/// <summary>
/// Apply a config change in place without tearing down the driver process.
/// Used by <c>IGenerationApplier</c> when only this driver's config changed in the new generation.
/// Invoked by the v2 <c>DriverInstanceActor</c> when ApplyDelta reports that only this
/// driver's config changed in the new deployment.
/// </summary>
/// <remarks>
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the

View File

@@ -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<DriverInstance>? drivers = null,
IReadOnlyList<Equipment>? equipment = null,
IReadOnlyList<Tag>? 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<string>();
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<string>();
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<OperationCanceledException>(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<string>();
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<OperationCanceledException>(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");
}
}