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:
Joseph Doherty
2026-11-09 13:16:33 -05:00
parent d50433410d
commit 6070cc2b9d
11 changed files with 451 additions and 18 deletions
@@ -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>());
}
}