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;
///
/// Memoises the repository reads that performs,
/// for the lifetime of ONE flatten/validate session.
///
///
/// 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.
///
///
///
/// Cache key. Templates are keyed on (id, ITemplateGraphWatermark
/// version) and chain MEMBERSHIP additionally on the watermark's
/// , 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.
///
///
///
/// Threading. A session is NOT thread-safe and must not be shared across
/// threads. It caches entities materialised by one scoped DbContext, which
/// is itself single-threaded, and it hands out tracked entities that the
/// in-flight bundle importer relies on observing. DeploySiteAsync 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.
///
///
public sealed class FlattenSession
{
private readonly ITemplateGraphWatermark _watermark;
private readonly Dictionary _templates = new();
private readonly Dictionary _chains = new();
private readonly Dictionary Compositions)> _compositions = new();
private readonly Dictionary> _dataConnections = new();
private IReadOnlyList? _sharedScripts;
private IReadOnlyDictionary? _schemaLibrary;
/// Initializes a session bound to the process-wide graph watermark.
/// Watermark supplying the per-template versions used as cache keys.
public FlattenSession(ITemplateGraphWatermark watermark)
{
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
///
/// 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.
///
public int ChainLoads { get; private set; }
/// Number of single-template repository reads that missed the memo.
public int TemplateLoads { get; private set; }
/// Number of per-template composition queries that missed the memo.
public int CompositionLoads { get; private set; }
/// Number of times the session-global queries (shared scripts, schema library) ran.
public int GlobalLoads { get; private set; }
/// Number of per-site data-connection queries that missed the memo.
public int DataConnectionLoads { get; private set; }
///
/// 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.
///
public IReadOnlyCollection VisitedTemplateIds => _templates.Keys;
///
/// 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.
///
///
/// 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.
///
///
public IReadOnlyDictionary VisitedTemplateVersions =>
_templates.ToDictionary(entry => entry.Key, entry => entry.Value.Version);
///
/// Returns the inheritance chain for (the
/// template itself, then each ancestor), loading it through
/// on a miss.
///
/// Root of the chain to build.
/// Repository read for one template with its children.
/// The chain, ordered leaf-first.
public async Task> GetChainAsync(
int templateId,
Func> loadTemplate)
{
var structure = _watermark.StructureVersion;
if (_chains.TryGetValue(templateId, out var cached) && IsChainCurrent(cached, structure))
return cached.Chain;
var chain = new List();
var memberVersions = new Dictionary();
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();
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;
}
///
/// A memoised chain plus the exact watermark readings it was built under.
///
/// Structure version, covering chain MEMBERSHIP.
/// Per-template version of every link, covering chain CONTENTS.
/// The chain itself, ordered leaf-first.
private sealed record CachedChain(
long Structure,
IReadOnlyDictionary MemberVersions,
IReadOnlyList Chain);
///
/// A cached chain is current only when BOTH the graph shape and every member's
/// own version are unchanged.
///
///
/// 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.
///
///
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;
}
///
/// Returns one template with its children, loading it through
/// on a miss or when its watermark version moved.
///
/// Template to read.
/// Repository read for one template with its children.
/// The template, or when it does not exist.
public async Task GetTemplateAsync(int templateId, Func> 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;
}
///
/// Returns the composition rows declared directly on .
///
/// Template whose compositions are read.
/// Repository read for one template's compositions.
/// The composition rows.
public async Task> GetCompositionsAsync(
int templateId,
Func>> 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;
}
///
/// 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.
///
/// Site whose connections are read.
/// Repository read for a site's data connections.
/// The site's connections keyed by database id.
public async Task> GetDataConnectionsAsync(
int siteId,
Func>> loadConnections)
{
if (_dataConnections.TryGetValue(siteId, out var cached))
return cached;
var connections = await loadConnections(siteId);
DataConnectionLoads++;
_dataConnections[siteId] = connections;
return connections;
}
///
/// 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.
///
/// Repository read for all shared scripts.
/// The resolved shared scripts.
public async Task> GetSharedScriptsAsync(
Func>> loadSharedScripts)
{
if (_sharedScripts != null)
return _sharedScripts;
_sharedScripts = await loadSharedScripts();
GlobalLoads++;
return _sharedScripts;
}
///
/// Returns the shared JSON-Schema library as a name → schema-JSON map, hoisted
/// to the session for the same reason as the shared scripts.
///
/// Repository read building the schema library map.
/// The schema library.
public async Task> GetSchemaLibraryAsync(
Func>> loadSchemaLibrary)
{
if (_schemaLibrary != null)
return _schemaLibrary;
_schemaLibrary = await loadSchemaLibrary();
GlobalLoads++;
return _schemaLibrary;
}
}