perf(templates): resync and resolved-members read only the chain/subtree (ScadaBridge#89)
The last two whole-library template reads are gone. Both fed the inheritance-only TemplateInheritanceResolver, which walks one template's parent chain and never follows compositions. - TemplateService.ResyncInheritedMembersAsync (operator repair, CLI + editor) now reads #86's GetTemplateReconcileClosureAsync (derived subtree + the template's chain, tracked). When nothing derives from the template the closure is empty, so it falls back to the new tracked GetTemplateChainAsync - resyncing a leaf reads only its own chain. - ManagementActor.HandleGetResolvedTemplateMembers now reads GetTemplateChainAsync(id, trackChanges: false): the chain alone, untracked, with script bodies (the resolver projects script code, so #66's body-free analysis closure would not be equivalent). The chain walk is factored out of TemplateReconcileClosure into CollectChainAsync (one load per level, stops on parent cycles and dangling parents) and shared by both. Evidence: - TemplateChainReadTests (#86 fixture): for every template plus an unknown id, the closure-backed resync issues exactly the writes, audit row and result of the whole-graph resync with GetAllTemplatesAsync wired to throw; the resolver answers identically from the chain and the whole graph. 17 of these fail against the old service; 13 fail with the leaf chain fallback removed (mutation-checked). - TemplateChainRepositoryTests (SQLite, recorded SQL): a 4-deep chain loads 4 templates / 8 attributes instead of 46 / 92, one split-query set per level, untracked on request and tracked for the resync. - ResolvedTemplateMembersReadTests: the actor answers from the untracked chain read and never calls GetAllTemplatesAsync (fails on the old actor). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -233,8 +233,13 @@ template's derived subtree plus its own inheritance chain, tracked and fully inc
|
||||
one load per subtree depth and chain level (Gitea #86). The reconcile is
|
||||
inheritance-only, so every descendant's chain lies inside that set; a template with no
|
||||
children costs a single empty probe query. It still accepts an already-loaded tracked
|
||||
graph from a caller holding one. The operator `ResyncInheritedMembersAsync` and the
|
||||
flattener keep reading the whole tracked graph via `GetAllTemplatesAsync`.
|
||||
graph from a caller holding one. The operator `ResyncInheritedMembersAsync` reads the
|
||||
same reconcile closure and, when nothing derives from the template (empty closure),
|
||||
falls back to `GetTemplateChainAsync` — the template's own inheritance chain, tracked,
|
||||
one load per level — since resyncing a leaf reads only its chain (Gitea #89). The
|
||||
read-only resolved-members query (`GetResolvedTemplateMembersCommand`, behind the
|
||||
editor's inherited view and CLI `template resolved-members`) reads that chain alone,
|
||||
untracked. The flattener keeps reading the whole tracked graph via `GetAllTemplatesAsync`.
|
||||
|
||||
### Native Alarm Source Resolution
|
||||
|
||||
|
||||
+16
@@ -83,6 +83,22 @@ public interface ITemplateEngineRepository
|
||||
/// <returns>A task that resolves to the subtree followed by the template's chain.</returns>
|
||||
Task<IReadOnlyList<Template>> GetTemplateReconcileClosureAsync(int templateId, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Returns the fully-loaded templates (the <see cref="GetAllTemplatesAsync"/> shape: attributes,
|
||||
/// alarms, scripts with bodies, compositions, native alarm sources) of
|
||||
/// <paramref name="templateId"/>'s inheritance chain only — the template, its parent,
|
||||
/// grandparent, … (see
|
||||
/// <see cref="ZB.MOM.WW.ScadaBridge.Commons.Types.Templates.TemplateReconcileClosure.CollectChainAsync"/>).
|
||||
/// That is everything <c>TemplateInheritanceResolver.Resolve</c> reads for one template (Gitea #89):
|
||||
/// it backs the read-only resolved-members query (<paramref name="trackChanges"/> = false) and the
|
||||
/// inherited-member resync of a template with no descendants (tracked, because the resync writes
|
||||
/// through these entities). Returns an empty list when the template does not exist.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template whose chain to load.</param>
|
||||
/// <param name="trackChanges">Whether the returned entities are tracked by the context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task that resolves to the template followed by its ancestors.</returns>
|
||||
Task<IReadOnlyList<Template>> GetTemplateChainAsync(int templateId, bool trackChanges, CancellationToken cancellationToken = default);
|
||||
/// <summary>
|
||||
/// Returns an untracked identity/edge projection of every template (no children), ordered by name.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
|
||||
@@ -78,6 +78,44 @@ public static class TemplateReconcileClosure
|
||||
|
||||
// 2. The root's own inheritance chain, one query per level. A level already loaded as
|
||||
// part of the subtree means a parent cycle — the chain ends there.
|
||||
await WalkChainAsync(templateId, loaded, result, loadByIds, cancellationToken).ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects only the inheritance chain of <paramref name="templateId"/>: the template itself,
|
||||
/// its parent, grandparent, … (Gitea #89). This is everything
|
||||
/// <c>TemplateInheritanceResolver.Resolve</c> reads for one template — the resolver is
|
||||
/// inheritance-only — so it backs the read-only resolved-members query, and the inherited-member
|
||||
/// resync of a template nobody derives from. One <paramref name="loadByIds"/> call per level;
|
||||
/// a parent cycle or a dangling parent edge ends the chain.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template whose chain to load.</param>
|
||||
/// <param name="loadByIds">Loads the templates with the given ids (unknown ids are omitted).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The template followed by its ancestors, each once; empty when the template is unknown.</returns>
|
||||
public static async Task<IReadOnlyList<Template>> CollectChainAsync(
|
||||
int templateId,
|
||||
Func<IReadOnlyCollection<int>, CancellationToken, Task<IReadOnlyList<Template>>> loadByIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loadByIds);
|
||||
var result = new List<Template>();
|
||||
await WalkChainAsync(templateId, new Dictionary<int, Template>(), result, loadByIds, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Walks templateId's parent chain upward, loading each level not already in `loaded` (one
|
||||
// loadByIds call per level) and appending it to `result`. Ends on a repeated id (a parent
|
||||
// cycle) or a template that does not exist (a dangling parent edge, or an unknown root).
|
||||
private static async Task WalkChainAsync(
|
||||
int templateId,
|
||||
Dictionary<int, Template> loaded,
|
||||
List<Template> result,
|
||||
Func<IReadOnlyCollection<int>, CancellationToken, Task<IReadOnlyList<Template>>> loadByIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requested = new HashSet<int>();
|
||||
int? nextId = templateId;
|
||||
while (nextId is int id && requested.Add(id))
|
||||
@@ -97,7 +135,5 @@ public static class TemplateReconcileClosure
|
||||
result.Add(template);
|
||||
nextId = template.ParentTemplateId;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-2
@@ -161,8 +161,26 @@ public class TemplateEngineRepository : ITemplateEngineRepository
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// The tracked, fully-included template shape shared by GetAllTemplatesAsync and
|
||||
// GetTemplateReconcileClosureAsync.
|
||||
/// <inheritdoc />
|
||||
// One fully-included query per chain level (Gitea #89). Chains are short (a handful of
|
||||
// levels), so this is a few small reads instead of the whole library.
|
||||
public Task<IReadOnlyList<Template>> GetTemplateChainAsync(int templateId, bool trackChanges, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return TemplateReconcileClosure.CollectChainAsync(
|
||||
templateId,
|
||||
async (ids, ct) =>
|
||||
{
|
||||
var idList = ids.ToList();
|
||||
var query = FullTemplateGraph().Where(t => idList.Contains(t.Id));
|
||||
if (!trackChanges)
|
||||
query = query.AsNoTracking();
|
||||
return await query.ToListAsync(ct);
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// The tracked, fully-included template shape shared by GetAllTemplatesAsync,
|
||||
// GetTemplateReconcileClosureAsync and GetTemplateChainAsync.
|
||||
private IQueryable<Template> FullTemplateGraph()
|
||||
{
|
||||
return _context.Templates
|
||||
|
||||
@@ -869,15 +869,15 @@ public class ManagementActor : ReceiveActor
|
||||
/// <summary>
|
||||
/// Read-only authoring resolve: returns the effective inherited
|
||||
/// member set for a template — computed fresh from the full inheritance
|
||||
/// chain — plus a staleness summary. Loads every template (children
|
||||
/// eager-loaded) so the resolver can walk an arbitrary-depth chain; never
|
||||
/// mutates stored rows.
|
||||
/// chain — plus a staleness summary. The resolver is inheritance-only, so it
|
||||
/// loads just the template's own chain, untracked (Gitea #89) — never the
|
||||
/// whole library; never mutates stored rows.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleGetResolvedTemplateMembers(IServiceProvider sp, GetResolvedTemplateMembersCommand cmd)
|
||||
{
|
||||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||
var allTemplates = await repo.GetAllTemplatesAsync();
|
||||
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
|
||||
var chain = await repo.GetTemplateChainAsync(cmd.TemplateId, trackChanges: false);
|
||||
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, chain);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1220,7 +1220,15 @@ public class TemplateService
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var all = await _repository.GetAllTemplatesAsync(cancellationToken) ?? Array.Empty<Template>();
|
||||
// Every target's chain runs through templateId and on up its own chain, and the
|
||||
// resolver is inheritance-only, so the subtree + templateId's chain is everything
|
||||
// the resync reads (#89, reusing #86's closure). A template nobody derives from
|
||||
// has an empty reconcile closure; it still needs its own chain to resync itself.
|
||||
var all = await _repository.GetTemplateReconcileClosureAsync(templateId, cancellationToken)
|
||||
?? Array.Empty<Template>();
|
||||
if (all.Count == 0)
|
||||
all = await _repository.GetTemplateChainAsync(templateId, trackChanges: true, cancellationToken)
|
||||
?? Array.Empty<Template>();
|
||||
var byId = BuildTemplateLookup(all);
|
||||
if (!byId.TryGetValue(templateId, out var root))
|
||||
return Result<ResyncInheritedMembersResult>.Failure($"Template with ID {templateId} not found.");
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #89: <see cref="TemplateEngineRepository.GetTemplateChainAsync"/> reads only a template's
|
||||
/// inheritance chain — fully included like <see cref="TemplateEngineRepository.GetAllTemplatesAsync"/>,
|
||||
/// tracked or not on request — instead of the whole library. Asserted against real SQL on SQLite
|
||||
/// with the reader commands recorded.
|
||||
/// </summary>
|
||||
public class TemplateChainRepositoryTests : IDisposable
|
||||
{
|
||||
private const int UnrelatedTemplates = 40;
|
||||
private const string Body = "/* script body the resolver projects */";
|
||||
|
||||
private readonly SqlReaderCommandRecorder _recorder = new();
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly TemplateEngineRepository _repository;
|
||||
|
||||
/// <summary>Initializes the test class with an in-memory context and repository.</summary>
|
||||
public TemplateChainRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext(_recorder);
|
||||
_repository = new TemplateEngineRepository(_context, new TemplateGraphWatermark());
|
||||
}
|
||||
|
||||
/// <summary>Releases the resources held by this test class.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
private async Task<Template> AddAsync(string name, int? parentId = null)
|
||||
{
|
||||
var t = new Template(name) { ParentTemplateId = parentId };
|
||||
t.Attributes.Add(new TemplateAttribute($"{name}Attr1"));
|
||||
t.Attributes.Add(new TemplateAttribute($"{name}Attr2"));
|
||||
t.Scripts.Add(new TemplateScript($"{name}Script", Body));
|
||||
_context.Templates.Add(t);
|
||||
await _context.SaveChangesAsync();
|
||||
return t;
|
||||
}
|
||||
|
||||
private sealed record Seeded(int Root, int Base, int Mid, int Leaf, int Sibling, int Module);
|
||||
|
||||
private async Task<Seeded> SeedAsync()
|
||||
{
|
||||
var root = await AddAsync("Root");
|
||||
var @base = await AddAsync("Base", root.Id);
|
||||
var mid = await AddAsync("Mid", @base.Id);
|
||||
var leaf = await AddAsync("Leaf", mid.Id);
|
||||
var sibling = await AddAsync("Sibling", mid.Id); // Leaf's sibling: not on Leaf's chain
|
||||
var module = await AddAsync("Module");
|
||||
_context.Set<TemplateComposition>().Add(
|
||||
new TemplateComposition("m") { TemplateId = @base.Id, ComposedTemplateId = module.Id });
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
for (var i = 0; i < UnrelatedTemplates; i++)
|
||||
await AddAsync($"Unrelated{i}");
|
||||
|
||||
_context.ChangeTracker.Clear();
|
||||
_recorder.Reset();
|
||||
return new Seeded(root.Id, @base.Id, mid.Id, leaf.Id, sibling.Id, module.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The chain is exactly the template and its ancestors — 4 templates instead of 46 — with script
|
||||
/// bodies and every child collection, one full load per level; untracked when asked.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Chain_ReadsOnlyTemplateAndAncestors_Untracked()
|
||||
{
|
||||
var ids = await SeedAsync();
|
||||
|
||||
var whole = await _repository.GetAllTemplatesAsync();
|
||||
var wholeQueries = _recorder.Commands.Count;
|
||||
_context.ChangeTracker.Clear();
|
||||
_recorder.Reset();
|
||||
|
||||
var chain = await _repository.GetTemplateChainAsync(ids.Leaf, trackChanges: false);
|
||||
|
||||
Assert.Equal(new[] { ids.Leaf, ids.Mid, ids.Base, ids.Root }, chain.Select(t => t.Id));
|
||||
Assert.Equal(6 + UnrelatedTemplates, whole.Count);
|
||||
Assert.Equal(8, chain.Sum(t => t.Attributes.Count));
|
||||
Assert.All(chain.SelectMany(t => t.Scripts), s => Assert.Equal(Body, s.Code));
|
||||
Assert.Single(chain.Single(t => t.Id == ids.Base).Compositions);
|
||||
Assert.DoesNotContain(chain, t => t.Id == ids.Module || t.Id == ids.Sibling);
|
||||
|
||||
// One split-query set per level, each bounded by one template.
|
||||
Assert.Equal(4 * wholeQueries, _recorder.Commands.Count);
|
||||
Assert.Empty(_context.ChangeTracker.Entries());
|
||||
}
|
||||
|
||||
/// <summary>The resync's fallback read is tracked, because the resync writes through it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Chain_Tracked_WhenAsked_UnknownIdLoadsNothing()
|
||||
{
|
||||
var ids = await SeedAsync();
|
||||
|
||||
var chain = await _repository.GetTemplateChainAsync(ids.Mid, trackChanges: true);
|
||||
|
||||
Assert.Equal(new[] { ids.Mid, ids.Base, ids.Root }, chain.Select(t => t.Id));
|
||||
Assert.Equal(3, _context.ChangeTracker.Entries<Template>().Count());
|
||||
|
||||
Assert.Empty(await _repository.GetTemplateChainAsync(987654, trackChanges: true));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #89: the read-only <see cref="GetResolvedTemplateMembersCommand"/> resolves from the
|
||||
/// template's own inheritance chain, read untracked, instead of loading the whole tracked library.
|
||||
/// </summary>
|
||||
public class ResolvedTemplateMembersReadTests : TestKit
|
||||
{
|
||||
private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ServiceCollection _services = new ServiceCollection().AddTestConfigurationTransaction();
|
||||
|
||||
/// <summary>Initializes the harness: a Base → Child chain; the whole-library read throws.</summary>
|
||||
public ResolvedTemplateMembersReadTests()
|
||||
{
|
||||
_services.AddScoped(_ => _repo);
|
||||
|
||||
var baseT = new Template("Base") { Id = 7 };
|
||||
baseT.Attributes.Add(new TemplateAttribute("Speed") { Id = 70, TemplateId = 7, Value = "42" });
|
||||
var child = new Template("Child") { Id = 8, ParentTemplateId = 7, IsDerived = true };
|
||||
|
||||
_repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("resolved-members loaded the whole library"));
|
||||
_repo.GetTemplateChainAsync(8, false, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<Template> { child, baseT });
|
||||
}
|
||||
|
||||
private IActorRef CreateActor() =>
|
||||
Sys.ActorOf(Props.Create(() => new ManagementActor(
|
||||
_services.BuildServiceProvider(), NullLogger<ManagementActor>.Instance)));
|
||||
|
||||
private static ManagementEnvelope Viewer(object command) =>
|
||||
new(new AuthenticatedUser("v", "V", Array.Empty<string>(), Array.Empty<string>()),
|
||||
command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <summary>The resolved set comes from the untracked chain read; the library is never loaded.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ResolvedMembers_ResolveFromUntrackedChain_NotWholeLibrary()
|
||||
{
|
||||
CreateActor().Tell(Viewer(new GetResolvedTemplateMembersCommand(8)));
|
||||
|
||||
var success = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("\"Speed\"", success.JsonData, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("42", success.JsonData, StringComparison.Ordinal);
|
||||
await _repo.Received(1).GetTemplateChainAsync(8, false, Arg.Any<CancellationToken>());
|
||||
await _repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,8 @@ internal static class AnalysisClosureMock
|
||||
/// Gitea #86: the descendant reconcile now asks for the reconcile closure (derived subtree +
|
||||
/// the template's chain) instead of <c>GetAllTemplatesAsync</c>. Wires it to run the real
|
||||
/// closure walk over whatever the full tracked-graph setup returns, so every existing
|
||||
/// propagation scenario exercises the service against the reduced set.
|
||||
/// propagation scenario exercises the service against the reduced set. Also serves the
|
||||
/// chain-only read the inherited-member resync falls back to for a leaf (Gitea #89).
|
||||
/// </summary>
|
||||
/// <param name="repoMock">The repository mock.</param>
|
||||
/// <param name="closureRequests">Optional sink recording each requested closure root.</param>
|
||||
@@ -65,5 +66,14 @@ internal static class AnalysisClosureMock
|
||||
async (ids, token) => (await All(token)).Where(t => ids.Contains(t.Id)).ToList(),
|
||||
ct);
|
||||
});
|
||||
|
||||
// Gitea #89: the resync of a template nobody derives from falls back to its chain.
|
||||
repoMock
|
||||
.Setup(r => r.GetTemplateChainAsync(It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((int templateId, bool _, CancellationToken ct) =>
|
||||
TemplateReconcileClosure.CollectChainAsync(
|
||||
templateId,
|
||||
async (ids, token) => (await All(token)).Where(t => ids.Contains(t.Id)).ToList(),
|
||||
ct));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Text.Json;
|
||||
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.Templates;
|
||||
using static ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.TemplateReconcileClosureTests;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #89: the last two whole-library template reads — the operator resync
|
||||
/// (<see cref="TemplateService.ResyncInheritedMembersAsync"/>) and the read-only resolved-members
|
||||
/// query — now read only what the inheritance resolver walks: the resync reuses #86's reconcile
|
||||
/// closure (subtree + chain), falling back to the template's own chain when nothing derives from
|
||||
/// it, and resolved-members reads the chain alone. Pinned against the #86 fixture (deep subtree,
|
||||
/// ancestors above the edited template, a composed module, look-alike unrelated trees, a parent
|
||||
/// cycle, a dangling parent).
|
||||
/// </summary>
|
||||
public class TemplateChainReadTests
|
||||
{
|
||||
private static Task<IReadOnlyList<Template>> Chain(
|
||||
IReadOnlyList<Template> graph,
|
||||
int id,
|
||||
List<string>? calls = null) =>
|
||||
TemplateReconcileClosure.CollectChainAsync(
|
||||
id,
|
||||
(ids, _) =>
|
||||
{
|
||||
calls?.Add("ids:" + string.Join(",", ids.OrderBy(i => i)));
|
||||
return Task.FromResult<IReadOnlyList<Template>>(graph.Where(t => ids.Contains(t.Id)).ToList());
|
||||
});
|
||||
|
||||
/// <summary>Every fixture template id, plus one that does not exist.</summary>
|
||||
/// <returns>One row per id.</returns>
|
||||
public static IEnumerable<object[]> EveryTemplateAndUnknown() =>
|
||||
BuildGraph().Select(t => new object[] { t.Id }).Append(new object[] { 424242 });
|
||||
|
||||
/// <summary>
|
||||
/// The resolved-members query answers identically from the chain alone and from the whole
|
||||
/// library, for every template (resolved set + staleness, serialized).
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template resolved.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[MemberData(nameof(EveryTemplateAndUnknown))]
|
||||
public async Task ResolvedMembers_AnswerIdentically_FromChainAndWholeGraph(int templateId)
|
||||
{
|
||||
var graph = BuildGraph();
|
||||
var chain = await Chain(graph, templateId);
|
||||
|
||||
Assert.Equal(
|
||||
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(templateId, graph), Json),
|
||||
JsonSerializer.Serialize(TemplateInheritanceResolver.Resolve(templateId, chain), Json));
|
||||
}
|
||||
|
||||
/// <summary>The chain is the template and its ancestors — one load per level, nothing else.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Chain_IsTemplateAndAncestorsOnly_OneLoadPerLevel()
|
||||
{
|
||||
var calls = new List<string>();
|
||||
var chain = await Chain(BuildGraph(), GreatLeaf, calls);
|
||||
|
||||
Assert.Equal(new[] { GreatLeaf, Leaf1, Mid, Base, Root }, chain.Select(t => t.Id));
|
||||
Assert.Equal(new[] { "ids:7", "ids:4", "ids:3", "ids:2", "ids:1" }, calls);
|
||||
Assert.DoesNotContain(chain, t => t.Id == Mod || t.Id == U1);
|
||||
}
|
||||
|
||||
/// <summary>A parent cycle and a dangling parent both end the chain; an unknown id loads nothing.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Chain_TerminatesOnCyclesDanglingParentsAndUnknownIds()
|
||||
{
|
||||
var graph = BuildGraph();
|
||||
Assert.Equal(new[] { CycC, CycA, CycB }, (await Chain(graph, CycC)).Select(t => t.Id));
|
||||
Assert.Equal(new[] { DangKid, Dang }, (await Chain(graph, DangKid)).Select(t => t.Id));
|
||||
Assert.Empty(await Chain(graph, 424242));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End to end through <see cref="TemplateService.ResyncInheritedMembersAsync"/>: resyncing from
|
||||
/// the closure (with the whole-library load wired to throw) issues exactly the writes, audit and
|
||||
/// result of the old whole-graph resync, for every template and for an unknown id.
|
||||
/// </summary>
|
||||
/// <param name="templateId">The template resynced.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Theory]
|
||||
[MemberData(nameof(EveryTemplateAndUnknown))]
|
||||
public async Task Resync_IssuesIdenticalWritesAndResult_FromClosureAndWholeGraph(int templateId)
|
||||
{
|
||||
var expected = await RecordResync(templateId, useClosure: false);
|
||||
var actual = await RecordResync(templateId, useClosure: true);
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The equivalence is not vacuous: resyncing the leaf <c>GreatLeaf</c> (empty reconcile closure,
|
||||
/// so the chain fallback is load-bearing) materializes the members it inherits from four levels
|
||||
/// up, resyncing <c>Leaf1</c> removes its orphan, and resyncing <c>Base</c> repairs the subtree.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Fixture_ResyncWritesRepairs_ForLeafAndSubtree()
|
||||
{
|
||||
var leaf = await RecordResync(GreatLeaf, useClosure: true);
|
||||
Assert.Contains(leaf, w => w.StartsWith("AddTemplateAttributeAsync", StringComparison.Ordinal));
|
||||
Assert.Contains(leaf, w => w.StartsWith("Result:Success", StringComparison.Ordinal));
|
||||
|
||||
var leaf1 = await RecordResync(Leaf1, useClosure: true);
|
||||
Assert.Contains(leaf1, w => w.StartsWith("DeleteTemplateAttributeAsync", StringComparison.Ordinal));
|
||||
|
||||
var subtree = await RecordResync(Base, useClosure: true);
|
||||
Assert.Contains(subtree, w => w.StartsWith("UpdateTemplateScriptAsync", StringComparison.Ordinal));
|
||||
Assert.Contains(subtree, w => w.StartsWith("UpdateTemplateAlarmAsync", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the resync on a fresh copy of the graph and returns every write the service issued, the
|
||||
/// audit row and the result, rendered as sorted strings.
|
||||
/// </summary>
|
||||
private static async Task<List<string>> RecordResync(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("resync loaded the whole tracked graph"));
|
||||
repo.Setup(r => r.GetTemplateReconcileClosureAsync(templateId, It.IsAny<CancellationToken>()))
|
||||
.Returns((int id, CancellationToken _) => Closure(graph, id));
|
||||
// The resync writes through what it loads: the chain fallback must be the tracked read.
|
||||
repo.Setup(r => r.GetTemplateChainAsync(templateId, true, It.IsAny<CancellationToken>()))
|
||||
.Returns((int id, bool _, CancellationToken _) => Chain(graph, id));
|
||||
}
|
||||
else
|
||||
{
|
||||
repo.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(graph);
|
||||
repo.Setup(r => r.GetTemplateReconcileClosureAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(graph);
|
||||
repo.Setup(r => r.GetTemplateChainAsync(It.IsAny<int>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(graph);
|
||||
}
|
||||
|
||||
var service = new TemplateService(repo.Object, audit.Object);
|
||||
var result = await service.ResyncInheritedMembersAsync(templateId, "tester");
|
||||
|
||||
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))) + ")";
|
||||
|
||||
return 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))
|
||||
.Append(result.IsSuccess
|
||||
? "Result:Success " + JsonSerializer.Serialize(result.Value, Json)
|
||||
: "Result:Failure " + result.Error)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,17 @@ namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;
|
||||
/// </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;
|
||||
internal const int Root = 1, Base = 2, Mid = 3, Leaf1 = 4, Leaf2 = 5, Leaf3 = 6, GreatLeaf = 7, Mod = 8;
|
||||
internal const int U1 = 9, U2 = 10, CycA = 11, CycB = 12, CycC = 13, Dang = 14, DangKid = 15;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new()
|
||||
internal 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()
|
||||
internal static List<Template> BuildGraph()
|
||||
{
|
||||
var memberId = 1000;
|
||||
Template T(int id, string name, int? parent = null)
|
||||
@@ -112,7 +112,7 @@ public class TemplateReconcileClosureTests
|
||||
};
|
||||
}
|
||||
|
||||
private static Task<IReadOnlyList<Template>> Closure(
|
||||
internal static Task<IReadOnlyList<Template>> Closure(
|
||||
IReadOnlyList<Template> graph,
|
||||
int id,
|
||||
List<string>? calls = null) =>
|
||||
|
||||
Reference in New Issue
Block a user