perf(deploy): flatten-session caching, bulk DeploySiteAsync, paged management queries

This commit is contained in:
Joseph Doherty
2026-08-14 21:14:22 -04:00
parent ee193cd2bb
commit 48b3c40a7f
59 changed files with 3051 additions and 231 deletions
@@ -1,3 +1,5 @@
using System.Collections.Concurrent;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
@@ -10,21 +12,70 @@ namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the
/// Transport component (which references only Commons + TemplateEngine) can probe
/// staleness without taking a DeploymentManager project reference.
///
/// <para>
/// <b>Watermark fast path.</b> A bundle import probes EVERY instance derived from
/// an overwritten template, and the Deployments page sweeps the fleet — both pay a
/// full flatten per instance for an answer that is usually "unchanged". A probe
/// result is therefore memoised against the
/// <see cref="ITemplateGraphWatermark"/> reading that produced it: the instance's
/// own version, the graph's structure version (which covers chain MEMBERSHIP), and
/// the version of every template the flatten actually walked. When all of those are
/// unchanged the recorded hash is still current by construction and the flatten is
/// skipped.
/// </para>
///
/// <para>
/// The fast path can only ever cause EXTRA work, never stale work: the watermark is
/// in-memory and process-local, so a restart, a failover to the other central node,
/// or any mutation at all simply misses and falls back to the authoritative
/// flatten.
/// </para>
/// </summary>
public sealed class StaleInstanceProbe : IStaleInstanceProbe
{
/// <summary>
/// Ceiling on memoised probe results. Bounded because the key space is the
/// instance table and this cache is process-wide and static; on overflow it is
/// dropped wholesale, which is safe here (unlike the script-compile verdict
/// cache) because a miss costs one flatten and leaks nothing.
/// </summary>
private const int MaxCachedProbes = 4096;
private static readonly ConcurrentDictionary<int, ProbeMemo> Memos = new();
private readonly IFlatteningPipeline _flatteningPipeline;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>Initializes a new <see cref="StaleInstanceProbe"/>.</summary>
/// <param name="flatteningPipeline">The deployment flattening pipeline used to recompute the current revision hash.</param>
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline)
/// <param name="watermark">Graph version watermark backing the fast path.</param>
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline, ITemplateGraphWatermark watermark)
{
_flatteningPipeline = flatteningPipeline ?? throw new ArgumentNullException(nameof(flatteningPipeline));
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
/// <summary>
/// A previously computed revision hash together with the exact watermark
/// readings it was computed under.
/// </summary>
/// <param name="Structure">Graph structure version at flatten time.</param>
/// <param name="InstanceVersion">The instance's own version at flatten time.</param>
/// <param name="TemplateVersions">Version of every template the flatten walked, by template id.</param>
/// <param name="RevisionHash">The hash that reading produced.</param>
private sealed record ProbeMemo(
long Structure,
long InstanceVersion,
IReadOnlyDictionary<int, long> TemplateVersions,
string RevisionHash);
/// <inheritdoc />
public async Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
{
if (TryGetMemoisedHash(instanceId, out var memoised))
return memoised;
// The pipeline returns a Result; a flatten failure (e.g. unresolvable
// template chain mid-import) yields null so the caller treats the
// instance as "staleness indeterminate" and skips it. Flattening reuses
@@ -33,9 +84,63 @@ public sealed class StaleInstanceProbe : IStaleInstanceProbe
// Staleness only needs the revision hash — skip the expensive Roslyn
// script-compilation stage (this probe is called per-instance across a whole
// bundle import in BundleImporter.ComputeStaleInstanceIdsAsync).
//
// Watermark readings are taken BEFORE the flatten. A mutation that lands
// during the flatten therefore bumps a version past the recorded one and
// invalidates the memo on the next read, rather than being captured as if
// it had already been included.
var structure = _watermark.StructureVersion;
var instanceVersion = _watermark.GetInstanceVersion(instanceId);
var session = _flatteningPipeline.CreateSession();
var result = await _flatteningPipeline
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false)
.FlattenAndValidateAsync(instanceId, cancellationToken, validateScripts: false, session)
.ConfigureAwait(false);
return result.IsSuccess ? result.Value.RevisionHash : null;
if (result.IsFailure)
return null;
if (Memos.Count >= MaxCachedProbes)
Memos.Clear();
var visited = session.VisitedTemplateIds;
var versions = new Dictionary<int, long>(visited.Count);
foreach (var templateId in visited)
versions[templateId] = _watermark.GetTemplateVersion(templateId);
Memos[instanceId] = new ProbeMemo(structure, instanceVersion, versions, result.Value.RevisionHash);
return result.Value.RevisionHash;
}
/// <summary>
/// Returns the memoised revision hash when every watermark reading it was
/// computed under is still current.
/// </summary>
private bool TryGetMemoisedHash(int instanceId, out string? hash)
{
hash = null;
if (!Memos.TryGetValue(instanceId, out var memo))
return false;
if (memo.Structure != _watermark.StructureVersion)
return false;
if (memo.InstanceVersion != _watermark.GetInstanceVersion(instanceId))
return false;
foreach (var (templateId, version) in memo.TemplateVersions)
{
if (_watermark.GetTemplateVersion(templateId) != version)
return false;
}
hash = memo.RevisionHash;
return true;
}
/// <summary>
/// Drops every memoised probe result. Test hook — production invalidation is
/// driven entirely by the watermark.
/// </summary>
public static void ClearMemos() => Memos.Clear();
}