Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/FlattenSession.cs
T

275 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// <summary>
/// Memoises the repository reads that <see cref="FlatteningPipeline"/> performs,
/// for the lifetime of ONE flatten/validate session.
///
/// <para>
/// Without a session every instance flatten re-walks its whole template
/// inheritance chain (one query per link), re-loads the compositions of every
/// template in that chain and of every composed chain it reaches, and re-issues
/// the three session-global queries — shared scripts, the shared-schema library,
/// and the target site's data connections. For a bulk deploy of N instances off
/// the same template that is N× the same work; even a single instance re-loads a
/// composed chain once per composing template.
/// </para>
///
/// <para>
/// <b>Cache key.</b> Templates are keyed on <c>(id, ITemplateGraphWatermark
/// version)</c> and chain MEMBERSHIP additionally on the watermark's
/// <see cref="ITemplateGraphWatermark.StructureVersion"/>, so a template edited
/// mid-session is re-read rather than served from the memo. The session is also
/// short-lived by construction — it is created per deploy/validate operation and
/// discarded with it — so a cached graph can never outlive the operation that
/// captured it. That is the design's "template state is captured at the time of
/// flatten" guarantee, unchanged: the session narrows the capture window, it does
/// not widen it.
/// </para>
///
/// <para>
/// <b>Threading.</b> A session is NOT thread-safe and must not be shared across
/// threads. It caches entities materialised by one scoped <c>DbContext</c>, which
/// is itself single-threaded, and it hands out tracked entities that the
/// in-flight bundle importer relies on observing. <c>DeploySiteAsync</c> honours
/// this by running the whole prepare (flatten) phase serially on one scope and
/// parallelising only the site round-trips, which touch no repository at all.
/// </para>
/// </summary>
public sealed class FlattenSession
{
private readonly ITemplateGraphWatermark _watermark;
private readonly Dictionary<int, (long Version, Template Template)> _templates = new();
private readonly Dictionary<int, CachedChain> _chains = new();
private readonly Dictionary<int, (long Version, IReadOnlyList<TemplateComposition> Compositions)> _compositions = new();
private readonly Dictionary<int, IReadOnlyDictionary<int, DataConnection>> _dataConnections = new();
private IReadOnlyList<ResolvedScript>? _sharedScripts;
private IReadOnlyDictionary<string, string>? _schemaLibrary;
/// <summary>Initializes a session bound to the process-wide graph watermark.</summary>
/// <param name="watermark">Watermark supplying the per-template versions used as cache keys.</param>
public FlattenSession(ITemplateGraphWatermark watermark)
{
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
/// <summary>
/// Number of template-chain walks that actually hit the repository. Reset
/// never; used by tests to assert that N instances sharing a template load
/// that chain exactly once.
/// </summary>
public int ChainLoads { get; private set; }
/// <summary>Number of single-template repository reads that missed the memo.</summary>
public int TemplateLoads { get; private set; }
/// <summary>Number of per-template composition queries that missed the memo.</summary>
public int CompositionLoads { get; private set; }
/// <summary>Number of times the session-global queries (shared scripts, schema library) ran.</summary>
public int GlobalLoads { get; private set; }
/// <summary>Number of per-site data-connection queries that missed the memo.</summary>
public int DataConnectionLoads { get; private set; }
/// <summary>
/// Every template id this session resolved — the union of all inheritance and
/// composed chains it walked. The staleness fast path records the watermark
/// version of exactly this set, so a change to ANY template that fed a
/// flatten invalidates the memoised revision hash.
/// </summary>
public IReadOnlyCollection<int> VisitedTemplateIds => _templates.Keys;
/// <summary>
/// Every visited template id paired with the watermark version this session
/// READ it at — the pre-flatten watermark the staleness fast path must memoise
/// against.
///
/// <para>
/// Re-reading the versions after the flatten instead is a correctness bug, not
/// an optimisation detail: an edit that lands mid-flatten pairs the PRE-edit
/// content (and therefore the pre-edit hash) with the POST-edit version, and
/// the memo then answers "not stale" until something else moves the watermark.
/// Recording the version each template was read at can only ever cause an extra
/// flatten, never a stale answer.
/// </para>
/// </summary>
public IReadOnlyDictionary<int, long> VisitedTemplateVersions =>
_templates.ToDictionary(entry => entry.Key, entry => entry.Value.Version);
/// <summary>
/// Returns the inheritance chain for <paramref name="templateId"/> (the
/// template itself, then each ancestor), loading it through
/// <paramref name="loadTemplate"/> on a miss.
/// </summary>
/// <param name="templateId">Root of the chain to build.</param>
/// <param name="loadTemplate">Repository read for one template with its children.</param>
/// <returns>The chain, ordered leaf-first.</returns>
public async Task<IReadOnlyList<Template>> GetChainAsync(
int templateId,
Func<int, Task<Template?>> loadTemplate)
{
var structure = _watermark.StructureVersion;
if (_chains.TryGetValue(templateId, out var cached) && IsChainCurrent(cached, structure))
return cached.Chain;
var chain = new List<Template>();
var memberVersions = new Dictionary<int, long>();
var currentId = (int?)templateId;
// Guard against a cyclic parent edge. Acyclicity is enforced on save, but
// this walk must not hang if a bad row ever reaches the database.
var seen = new HashSet<int>();
while (currentId.HasValue && seen.Add(currentId.Value))
{
memberVersions[currentId.Value] = _watermark.GetTemplateVersion(currentId.Value);
var template = await GetTemplateAsync(currentId.Value, loadTemplate);
if (template == null) break;
chain.Add(template);
currentId = template.ParentTemplateId;
}
ChainLoads++;
_chains[templateId] = new CachedChain(structure, memberVersions, chain);
return chain;
}
/// <summary>
/// A memoised chain plus the exact watermark readings it was built under.
/// </summary>
/// <param name="Structure">Structure version, covering chain MEMBERSHIP.</param>
/// <param name="MemberVersions">Per-template version of every link, covering chain CONTENTS.</param>
/// <param name="Chain">The chain itself, ordered leaf-first.</param>
private sealed record CachedChain(
long Structure,
IReadOnlyDictionary<int, long> MemberVersions,
IReadOnlyList<Template> Chain);
/// <summary>
/// A cached chain is current only when BOTH the graph shape and every member's
/// own version are unchanged.
///
/// <para>
/// Both halves are load-bearing. Structure alone would miss an ordinary member
/// edit (an attribute added to a template already in the chain), which changes
/// what the flattener produces without changing which templates it walks.
/// Member versions alone would miss a re-parent, which changes membership
/// without touching any surviving member. Checking only one would let a session
/// serve a chain that no longer reflects the database.
/// </para>
/// </summary>
private bool IsChainCurrent(CachedChain cached, long structure)
{
if (cached.Structure != structure)
return false;
foreach (var (templateId, version) in cached.MemberVersions)
{
if (_watermark.GetTemplateVersion(templateId) != version)
return false;
}
return true;
}
/// <summary>
/// Returns one template with its children, loading it through
/// <paramref name="loadTemplate"/> on a miss or when its watermark version moved.
/// </summary>
/// <param name="templateId">Template to read.</param>
/// <param name="loadTemplate">Repository read for one template with its children.</param>
/// <returns>The template, or <see langword="null"/> when it does not exist.</returns>
public async Task<Template?> GetTemplateAsync(int templateId, Func<int, Task<Template?>> loadTemplate)
{
var version = _watermark.GetTemplateVersion(templateId);
if (_templates.TryGetValue(templateId, out var cached) && cached.Version == version)
return cached.Template;
var template = await loadTemplate(templateId);
TemplateLoads++;
if (template != null)
_templates[templateId] = (version, template);
return template;
}
/// <summary>
/// Returns the composition rows declared directly on <paramref name="templateId"/>.
/// </summary>
/// <param name="templateId">Template whose compositions are read.</param>
/// <param name="loadCompositions">Repository read for one template's compositions.</param>
/// <returns>The composition rows.</returns>
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsAsync(
int templateId,
Func<int, Task<IReadOnlyList<TemplateComposition>>> loadCompositions)
{
var version = _watermark.GetTemplateVersion(templateId);
if (_compositions.TryGetValue(templateId, out var cached) && cached.Version == version)
return cached.Compositions;
var compositions = await loadCompositions(templateId);
CompositionLoads++;
_compositions[templateId] = (version, compositions);
return compositions;
}
/// <summary>
/// Returns the site's data connections keyed by id. Cached per site for the
/// session — a bulk site deploy resolves them once for the whole batch.
/// </summary>
/// <param name="siteId">Site whose connections are read.</param>
/// <param name="loadConnections">Repository read for a site's data connections.</param>
/// <returns>The site's connections keyed by database id.</returns>
public async Task<IReadOnlyDictionary<int, DataConnection>> GetDataConnectionsAsync(
int siteId,
Func<int, Task<IReadOnlyDictionary<int, DataConnection>>> loadConnections)
{
if (_dataConnections.TryGetValue(siteId, out var cached))
return cached;
var connections = await loadConnections(siteId);
DataConnectionLoads++;
_dataConnections[siteId] = connections;
return connections;
}
/// <summary>
/// Returns the shared-script set used for semantic validation. Hoisted to the
/// session so a bulk deploy issues this query once instead of once per instance.
/// </summary>
/// <param name="loadSharedScripts">Repository read for all shared scripts.</param>
/// <returns>The resolved shared scripts.</returns>
public async Task<IReadOnlyList<ResolvedScript>> GetSharedScriptsAsync(
Func<Task<IReadOnlyList<ResolvedScript>>> loadSharedScripts)
{
if (_sharedScripts != null)
return _sharedScripts;
_sharedScripts = await loadSharedScripts();
GlobalLoads++;
return _sharedScripts;
}
/// <summary>
/// Returns the shared JSON-Schema library as a name → schema-JSON map, hoisted
/// to the session for the same reason as the shared scripts.
/// </summary>
/// <param name="loadSchemaLibrary">Repository read building the schema library map.</param>
/// <returns>The schema library.</returns>
public async Task<IReadOnlyDictionary<string, string>> GetSchemaLibraryAsync(
Func<Task<IReadOnlyDictionary<string, string>>> loadSchemaLibrary)
{
if (_schemaLibrary != null)
return _schemaLibrary;
_schemaLibrary = await loadSchemaLibrary();
GlobalLoads++;
return _schemaLibrary;
}
}