275 lines
12 KiB
C#
275 lines
12 KiB
C#
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;
|
||
}
|
||
}
|