c29b99c63b
TemplateService.ReconcileDescendantsAsync - run after every base member add/update/delete, template and native-alarm-source alike - loaded the whole tracked template graph (every template, member and script body) on each call, which after #66 was the dominant cost of those paths. It now calls the new ITemplateEngineRepository.GetTemplateReconcileClosureAsync: the same tracked, fully-included shape as GetAllTemplatesAsync, restricted to the edited template's derived subtree plus its own inheritance chain. The reconcile is inheritance-only (TemplateInheritanceResolver never follows compositions) and every descendant's chain runs through the edited template, so that set is everything it reads. The walk (Commons TemplateReconcileClosure, the descendant-direction twin of #66's TemplateAnalysisClosure) loads one children batch per subtree depth and one batch per ancestor level, terminates on parent cycles and stops at dangling parents. A template with no children - the common case - costs one empty probe and loads nothing. Evidence: - TemplateReconcileClosureTests: for every template of a graph with a deep subtree, an ancestor above the edited template, a composed module, look-alike unrelated trees, a parent cycle and a dangling parent, the closure-backed reconcile issues exactly the writes (add/update/delete, audit, save) of the old whole-graph reconcile, and the resolver answers identically for every descendant. Fails with the old service (whole-graph load wired to throw) and with the ancestor walk removed (mutation-checked). - TemplateReconcileClosureRepositoryTests (SQLite, recorded SQL): 5 templates / 10 attributes instead of 47 / 94, tracked with script bodies; a leaf costs one probe query and tracks nothing. - TemplateServiceTests: attribute add + update propagate two levels down with GetAllTemplatesAsync wired to throw; existing propagation tests now run against the closure via ServeReconcileClosureFromFullGraph. ResyncInheritedMembersAsync (operator action) still reads the whole graph. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
315 lines
14 KiB
C#
315 lines
14 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Moq;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #86: <c>TemplateService.ReconcileDescendantsAsync</c> now reconciles against the
|
|
/// template's derived subtree + inheritance chain instead of the whole tracked library. These
|
|
/// tests pin that the closure is (a) sufficient — for every template of a graph with a deep
|
|
/// subtree, an ancestor chain above the edited template, a composed module, unrelated look-alike
|
|
/// trees, a parent cycle and a dangling parent, the reconcile issues exactly the same writes and
|
|
/// the resolver answers identically for every descendant — and (b) actually smaller, loading
|
|
/// nothing beyond one probe when there are no descendants.
|
|
/// </summary>
|
|
public class TemplateReconcileClosureTests
|
|
{
|
|
private const int Root = 1, Base = 2, Mid = 3, Leaf1 = 4, Leaf2 = 5, Leaf3 = 6, GreatLeaf = 7, Mod = 8;
|
|
private const int U1 = 9, U2 = 10, CycA = 11, CycB = 12, CycC = 13, Dang = 14, DangKid = 15;
|
|
|
|
private static readonly JsonSerializerOptions Json = new()
|
|
{
|
|
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
|
WriteIndented = false,
|
|
};
|
|
|
|
/// <summary>Builds a fresh, independently mutable copy of the fixture graph.</summary>
|
|
private static List<Template> BuildGraph()
|
|
{
|
|
var memberId = 1000;
|
|
Template T(int id, string name, int? parent = null)
|
|
=> new(name) { Id = id, ParentTemplateId = parent };
|
|
|
|
void Attr(Template t, string name, string? value, bool inherited = false, bool locked = false) =>
|
|
t.Attributes.Add(new TemplateAttribute(name)
|
|
{
|
|
Id = memberId++, TemplateId = t.Id, Value = value, DataType = DataType.String,
|
|
IsInherited = inherited, IsLocked = locked,
|
|
});
|
|
|
|
void Script(Template t, string name, string code, bool inherited = false) =>
|
|
t.Scripts.Add(new TemplateScript(name, code)
|
|
{
|
|
Id = memberId++, TemplateId = t.Id, TriggerType = "Startup", IsInherited = inherited,
|
|
});
|
|
|
|
void Alarm(Template t, string name, int priority, bool inherited = false) =>
|
|
t.Alarms.Add(new TemplateAlarm(name)
|
|
{
|
|
Id = memberId++, TemplateId = t.Id, PriorityLevel = priority,
|
|
TriggerType = AlarmTriggerType.ValueMatch, IsInherited = inherited,
|
|
});
|
|
|
|
var root = T(Root, "Root");
|
|
Attr(root, "R", "root"); // inherited from ABOVE the edited template
|
|
Script(root, "Init", "root-body");
|
|
|
|
var @base = T(Base, "Base", Root);
|
|
Attr(@base, "A", "1");
|
|
Attr(@base, "L", "locked", locked: true);
|
|
Attr(@base, "New", "added-after-derivation"); // no placeholder anywhere yet
|
|
Script(@base, "Calc", "base-body-v2");
|
|
Alarm(@base, "Hi", 700);
|
|
@base.Compositions.Add(new TemplateComposition("m") { Id = memberId++, TemplateId = Base, ComposedTemplateId = Mod });
|
|
|
|
var mid = T(Mid, "Mid", Base);
|
|
Attr(mid, "A", "mid-override"); // own override, never touched
|
|
Attr(mid, "R", "stale-root", inherited: true);
|
|
Attr(mid, "L", "locked", inherited: true);
|
|
Script(mid, "Calc", "base-body-v1", inherited: true); // stale body
|
|
Alarm(mid, "Hi", 500, inherited: true); // stale priority
|
|
|
|
var leaf1 = T(Leaf1, "Leaf1", Mid);
|
|
Attr(leaf1, "A", "1", inherited: true); // stale: Mid overrides A
|
|
Attr(leaf1, "Gone", "orphan", inherited: true); // orphan placeholder
|
|
|
|
var leaf2 = T(Leaf2, "Leaf2", Mid);
|
|
Attr(leaf2, "L", "override-attempt"); // locked upstream
|
|
|
|
var leaf3 = T(Leaf3, "Leaf3", Base);
|
|
var greatLeaf = T(GreatLeaf, "GreatLeaf", Leaf1);
|
|
Script(greatLeaf, "Calc", "own-body");
|
|
|
|
var mod = T(Mod, "Mod");
|
|
Attr(mod, "ModOnly", "x");
|
|
|
|
// Unrelated look-alike tree: same member names, must never be loaded.
|
|
var u1 = T(U1, "U1");
|
|
Attr(u1, "A", "u");
|
|
Attr(u1, "New", "u");
|
|
var u2 = T(U2, "U2", U1);
|
|
Attr(u2, "A", "stale", inherited: true);
|
|
|
|
// Parent cycle with a hanging child, and a dangling parent edge.
|
|
var cycA = T(CycA, "CycA", CycB);
|
|
Attr(cycA, "CA", "a");
|
|
var cycB = T(CycB, "CycB", CycA);
|
|
Attr(cycB, "CB", "b");
|
|
var cycC = T(CycC, "CycC", CycA);
|
|
var dang = T(Dang, "Dang", 998);
|
|
Attr(dang, "D", "d");
|
|
var dangKid = T(DangKid, "DangKid", Dang);
|
|
|
|
return new List<Template>
|
|
{
|
|
root, @base, mid, leaf1, leaf2, leaf3, greatLeaf, mod, u1, u2, cycA, cycB, cycC, dang, dangKid,
|
|
};
|
|
}
|
|
|
|
private static Task<IReadOnlyList<Template>> Closure(
|
|
IReadOnlyList<Template> graph,
|
|
int id,
|
|
List<string>? calls = null) =>
|
|
TemplateReconcileClosure.CollectAsync(
|
|
id,
|
|
(parentIds, _) =>
|
|
{
|
|
calls?.Add("children:" + string.Join(",", parentIds.OrderBy(i => i)));
|
|
return Task.FromResult<IReadOnlyList<Template>>(
|
|
graph.Where(t => t.ParentTemplateId is int p && parentIds.Contains(p)).ToList());
|
|
},
|
|
(ids, _) =>
|
|
{
|
|
calls?.Add("ids:" + string.Join(",", ids.OrderBy(i => i)));
|
|
return Task.FromResult<IReadOnlyList<Template>>(graph.Where(t => ids.Contains(t.Id)).ToList());
|
|
});
|
|
|
|
// Independent re-statement of the service's descendant walk over a given template list.
|
|
private static List<int> Descendants(int rootId, IReadOnlyList<Template> all)
|
|
{
|
|
var result = new List<int>();
|
|
var seen = new HashSet<int> { rootId };
|
|
var queue = new Queue<int>(new[] { rootId });
|
|
while (queue.Count > 0)
|
|
{
|
|
var id = queue.Dequeue();
|
|
foreach (var child in all.Where(t => t.ParentTemplateId == id))
|
|
{
|
|
if (seen.Add(child.Id))
|
|
{
|
|
result.Add(child.Id);
|
|
queue.Enqueue(child.Id);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Every fixture template id.</summary>
|
|
/// <returns>One row per template.</returns>
|
|
public static IEnumerable<object[]> EveryTemplate() => BuildGraph().Select(t => new object[] { t.Id });
|
|
|
|
/// <summary>
|
|
/// The closure holds the same descendant set as the whole graph, and the inheritance resolver
|
|
/// gives every descendant the identical resolved member set from it.
|
|
/// </summary>
|
|
/// <param name="templateId">The edited template.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[MemberData(nameof(EveryTemplate))]
|
|
public async Task Resolver_AnswersIdenticallyForEveryDescendant(int templateId)
|
|
{
|
|
var graph = BuildGraph();
|
|
var closure = await Closure(graph, templateId);
|
|
|
|
var wholeDescendants = Descendants(templateId, graph);
|
|
Assert.Equal(wholeDescendants.OrderBy(i => i), Descendants(templateId, closure).OrderBy(i => i));
|
|
|
|
foreach (var d in wholeDescendants)
|
|
{
|
|
Assert.Equal(
|
|
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(d, graph), Json),
|
|
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(d, closure), Json));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// End to end through <see cref="TemplateService.ReconcileDescendantsAsync"/>: the closure-backed
|
|
/// reconcile issues exactly the writes (adds / updates / deletes / audit / save) the old
|
|
/// whole-graph reconcile did, for every template of the fixture.
|
|
/// </summary>
|
|
/// <param name="templateId">The edited template.</param>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Theory]
|
|
[MemberData(nameof(EveryTemplate))]
|
|
public async Task Reconcile_IssuesIdenticalWrites_FromClosureAndWholeGraph(int templateId)
|
|
{
|
|
var expected = await RecordReconcileWrites(templateId, useClosure: false);
|
|
var actual = await RecordReconcileWrites(templateId, useClosure: true);
|
|
Assert.Equal(expected, actual);
|
|
}
|
|
|
|
/// <summary>The equivalence above is not vacuous: editing Base really adds, updates and removes rows.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Fixture_ReconcilingBase_WritesAddsUpdatesAndDeletes()
|
|
{
|
|
var writes = await RecordReconcileWrites(Base, useClosure: true);
|
|
Assert.Contains(writes, w => w.StartsWith("AddTemplateAttributeAsync", StringComparison.Ordinal));
|
|
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateAttributeAsync", StringComparison.Ordinal));
|
|
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateScriptAsync", StringComparison.Ordinal));
|
|
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateAlarmAsync", StringComparison.Ordinal));
|
|
Assert.Contains(writes, w => w.StartsWith("DeleteTemplateAttributeAsync", StringComparison.Ordinal));
|
|
// Root's member reaches Mid only through Base's own chain — the ancestor walk is load-bearing.
|
|
Assert.Contains(writes, w => w.StartsWith("UpdateTemplateAttributeAsync", StringComparison.Ordinal)
|
|
&& w.Contains("\"Value\":\"root\"", StringComparison.Ordinal));
|
|
}
|
|
|
|
/// <summary>The closure is the subtree plus the chain — no module, no unrelated tree, no siblings.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_IsSubtreePlusChainOnly()
|
|
{
|
|
var graph = BuildGraph();
|
|
var closure = await Closure(graph, Base);
|
|
|
|
Assert.Equal(
|
|
new[] { Root, Base, Mid, Leaf1, Leaf2, Leaf3, GreatLeaf },
|
|
closure.Select(t => t.Id).OrderBy(i => i));
|
|
Assert.True(closure.Count < graph.Count);
|
|
|
|
// A mid-tree edit loads its own subtree and chain, not its siblings.
|
|
Assert.Equal(
|
|
new[] { Root, Base, Mid, Leaf1, Leaf2, GreatLeaf },
|
|
(await Closure(graph, Mid)).Select(t => t.Id).OrderBy(i => i));
|
|
}
|
|
|
|
/// <summary>A template with no children costs one children probe and loads nothing.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_NoDescendants_OneProbeAndEmpty()
|
|
{
|
|
var calls = new List<string>();
|
|
Assert.Empty(await Closure(BuildGraph(), Leaf2, calls));
|
|
Assert.Equal(new[] { "children:5" }, calls);
|
|
|
|
Assert.Empty(await Closure(BuildGraph(), 424242));
|
|
}
|
|
|
|
/// <summary>One children batch per subtree depth, one id batch per ancestor level, no id twice.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_LoadsOneBatchPerDepth()
|
|
{
|
|
var calls = new List<string>();
|
|
await Closure(BuildGraph(), Base, calls);
|
|
|
|
Assert.Equal(
|
|
new[] { "children:2", "children:3,6", "children:4,5", "children:7", "ids:2", "ids:1" },
|
|
calls);
|
|
}
|
|
|
|
/// <summary>Parent cycles terminate and a dangling parent ends the chain.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Closure_TerminatesOnCyclesAndDanglingParents()
|
|
{
|
|
var graph = BuildGraph();
|
|
Assert.Equal(new[] { CycA, CycB, CycC }, (await Closure(graph, CycA)).Select(t => t.Id).OrderBy(i => i));
|
|
Assert.Equal(new[] { Dang, DangKid }, (await Closure(graph, Dang)).Select(t => t.Id).OrderBy(i => i));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runs the reconcile for <paramref name="templateId"/> on a fresh copy of the graph and returns
|
|
/// every write the service issued, rendered as sorted strings (sibling order is not part of the
|
|
/// contract — each descendant is reconciled independently).
|
|
/// </summary>
|
|
private static async Task<List<string>> RecordReconcileWrites(int templateId, bool useClosure)
|
|
{
|
|
var graph = BuildGraph();
|
|
var repo = new Mock<ITemplateEngineRepository>();
|
|
var audit = new Mock<IAuditService>();
|
|
repo.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
|
|
|
|
if (useClosure)
|
|
{
|
|
repo.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("reconcile loaded the whole tracked graph"));
|
|
repo.Setup(r => r.GetTemplateReconcileClosureAsync(templateId, It.IsAny<CancellationToken>()))
|
|
.Returns((int id, CancellationToken _) => Closure(graph, id));
|
|
}
|
|
else
|
|
{
|
|
repo.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(graph);
|
|
}
|
|
|
|
var service = new TemplateService(repo.Object, audit.Object);
|
|
if (useClosure)
|
|
await service.ReconcileDescendantsAsync(templateId, "tester");
|
|
else
|
|
await service.ReconcileDescendantsAsync(templateId, "tester", default, graph);
|
|
|
|
static string Render(Moq.IInvocation i) =>
|
|
i.Method.Name + "(" + string.Join(", ", i.Arguments
|
|
.Where(a => a is not CancellationToken)
|
|
.Select(a => JsonSerializer.Serialize(a, Json))) + ")";
|
|
|
|
var writes = repo.Invocations
|
|
.Where(i => i.Method.Name.StartsWith("Add", StringComparison.Ordinal)
|
|
|| i.Method.Name.StartsWith("Update", StringComparison.Ordinal)
|
|
|| i.Method.Name.StartsWith("Delete", StringComparison.Ordinal)
|
|
|| i.Method.Name == nameof(ITemplateEngineRepository.SaveChangesAsync))
|
|
.Select(Render)
|
|
.Concat(audit.Invocations.Select(Render))
|
|
.OrderBy(s => s, StringComparer.Ordinal)
|
|
.ToList();
|
|
return writes;
|
|
}
|
|
}
|