Merge branch 'worktree-agent-a0098b40576d74cfd' into arch-review-remediation

This commit is contained in:
Joseph Doherty
2026-08-14 21:15:32 -04:00
59 changed files with 3051 additions and 231 deletions
@@ -277,6 +277,89 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
return expired.Count;
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecordSummary>> QueryDeploymentSummariesAsync(
int? instanceId,
DeploymentStatus? status,
IReadOnlyCollection<int>? instanceIdScope,
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _dbContext.DeploymentRecords.AsNoTracking();
if (instanceId.HasValue)
query = query.Where(d => d.InstanceId == instanceId.Value);
if (status.HasValue)
query = query.Where(d => d.Status == status.Value);
if (instanceIdScope != null)
{
// Site scoping applied DB-side. An empty scope is a real, meaningful
// filter — a user permitted no in-scope instances sees nothing — so it
// must not be short-circuited into "no filter".
var scope = instanceIdScope as int[] ?? instanceIdScope.ToArray();
query = query.Where(d => scope.Contains(d.InstanceId));
}
// ThenByDescending(Id) for the same reason GetCurrentDeploymentStatusAsync
// does it: DeployedAt ties on rapid redeploys, and an unstable sort key
// makes Skip/Take paging non-deterministic (rows repeat or vanish between
// pages).
var paged = query
.OrderByDescending(d => d.DeployedAt)
.ThenByDescending(d => d.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
paged = paged.Take(Math.Min(take.Value, DeploymentRecordSummary.MaxPageSize));
return await paged
.Select(d => new DeploymentRecordSummary(
d.Id,
d.DeploymentId,
d.InstanceId,
d.Status,
d.RevisionHash,
d.DeployedBy,
d.DeployedAt,
d.CompletedAt,
d.ErrorMessage))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<int> PurgeTerminalDeploymentRecordsAsync(
DateTimeOffset cutoffUtc,
int batchSize,
CancellationToken cancellationToken = default)
{
// Terminal statuses ONLY. An InProgress (or Pending) row is never purged
// regardless of age: it is precisely the row TryReconcileWithSiteAsync
// looks for when deciding whether a prior deploy actually landed, and
// deleting it would silently disable the query-before-redeploy idempotency
// guard for that instance.
//
// Bounded batch rather than one unbounded DELETE so the first purge on a
// long-lived system cannot take a table-scale lock on DeploymentRecords.
// The caller re-invokes until it returns fewer rows than the batch size.
var stale = await _dbContext.DeploymentRecords
.Where(d => d.CompletedAt != null
&& d.CompletedAt < cutoffUtc
&& (d.Status == DeploymentStatus.Success || d.Status == DeploymentStatus.Failed))
.OrderBy(d => d.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
_dbContext.DeploymentRecords.RemoveRange(stale);
await _dbContext.SaveChangesAsync(cancellationToken);
return stale.Count;
}
// --- Startup reconciliation ---
/// <inheritdoc />
@@ -3,20 +3,32 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
public class TemplateEngineRepository : ITemplateEngineRepository
{
private readonly ScadaBridgeDbContext _context;
private readonly ITemplateGraphWatermark _watermark;
/// <summary>
/// Initializes a new instance of the TemplateEngineRepository class.
/// </summary>
/// <param name="context">The database context used to access template and instance data.</param>
public TemplateEngineRepository(ScadaBridgeDbContext context)
/// <param name="watermark">
/// Process-wide template/instance version watermark. This repository is the
/// single unit-of-work through which EVERY template-graph mutation commits —
/// <c>TemplateService</c>, the <c>ManagementActor</c> native-alarm-source
/// handlers, and the Transport bundle importer all funnel through the same
/// <see cref="SaveChangesAsync"/> — so bumping here (rather than in each
/// caller) is the only placement that cannot be bypassed.
/// </param>
public TemplateEngineRepository(ScadaBridgeDbContext context, ITemplateGraphWatermark watermark)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_watermark = watermark ?? throw new ArgumentNullException(nameof(watermark));
}
// Template
@@ -78,6 +90,95 @@ public class TemplateEngineRepository : ITemplateEngineRepository
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
/// <remarks>
/// Two deliberate departures from <see cref="GetAllTemplatesAsync"/>:
/// <list type="number">
/// <item>
/// <c>AsNoTracking</c> — the analysis walks (cycle detection, collision
/// detection, canonical-name resolution) never write through the loaded
/// graph, so paying for a change-tracker snapshot of every attribute,
/// alarm, script and composition in the database is pure waste.
/// </item>
/// <item>
/// <c>TemplateScript.Code</c> is projected away (left empty). Script
/// bodies are by far the largest column in the graph and NONE of the
/// analysis consumers read them — <c>CycleDetector</c> reads only ids and
/// edges, <c>CollisionDetector</c> and <c>TemplateResolver</c> read only
/// member names and lock flags.
/// </item>
/// </list>
/// Callers that DO need bodies or tracked entities — the inheritance
/// reconciler and the flattener — must keep using
/// <see cref="GetAllTemplatesAsync"/> / <see cref="GetTemplateWithChildrenAsync"/>.
/// </remarks>
public async Task<IReadOnlyList<Template>> GetAllTemplatesForAnalysisAsync(CancellationToken cancellationToken = default)
{
return await _context.Templates
.AsNoTracking()
.Select(t => new Template(t.Name)
{
Id = t.Id,
Description = t.Description,
ParentTemplateId = t.ParentTemplateId,
FolderId = t.FolderId,
IsDerived = t.IsDerived,
OwnerCompositionId = t.OwnerCompositionId,
Attributes = t.Attributes.ToList(),
Alarms = t.Alarms.ToList(),
NativeAlarmSources = t.NativeAlarmSources.ToList(),
Compositions = t.Compositions.ToList(),
Scripts = t.Scripts
.Select(s => new TemplateScript(s.Name, string.Empty)
{
Id = s.Id,
TemplateId = s.TemplateId,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
IsInherited = s.IsInherited,
LockedInDerived = s.LockedInDerived
})
.ToList()
})
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<TemplateSummary>> GetTemplateSummariesAsync(
int skip,
int? take,
CancellationToken cancellationToken = default)
{
var query = _context.Templates
.AsNoTracking()
.OrderBy(t => t.Id)
.Skip(Math.Max(0, skip));
if (take is > 0)
query = query.Take(Math.Min(take.Value, TemplateSummary.MaxPageSize));
return await query
.Select(t => new TemplateSummary(
t.Id,
t.Name,
t.Description,
t.ParentTemplateId,
t.FolderId,
t.IsDerived,
t.OwnerCompositionId,
t.Attributes.Count,
t.Alarms.Count,
t.Scripts.Count,
t.Compositions.Count,
t.NativeAlarmSources.Count))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<Template>> GetTemplatesComposingAsync(int composedTemplateId, CancellationToken cancellationToken = default)
{
@@ -659,8 +760,129 @@ public class TemplateEngineRepository : ITemplateEngineRepository
}
/// <inheritdoc />
/// <remarks>
/// Inspects the change tracker BEFORE committing (afterwards every entry is
/// <see cref="EntityState.Unchanged"/> and the attribution is lost) and bumps
/// the <see cref="ITemplateGraphWatermark"/> for each affected template /
/// instance. The bump is applied only AFTER a successful commit, so a failed
/// save does not invalidate caches for a change that never landed.
/// </remarks>
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
var pending = CollectWatermarkBumps();
var written = await _context.SaveChangesAsync(cancellationToken);
ApplyWatermarkBumps(pending);
return written;
}
/// <summary>
/// Snapshot of the watermark bumps implied by the current change-tracker
/// contents. Captured pre-commit, applied post-commit.
/// </summary>
private readonly record struct WatermarkBumps(
HashSet<int> Templates,
HashSet<int> StructuralTemplates,
HashSet<int> Instances,
bool Unattributed);
/// <summary>
/// Walks the change tracker and maps every added/modified/deleted
/// template-graph or instance-graph entry back to the template (or instance)
/// whose flattened output it can affect.
///
/// <para>
/// A change is treated as STRUCTURAL — invalidating cached chain membership,
/// not just chain contents — when it adds or removes a <see cref="Template"/>
/// row, changes a <see cref="Template.ParentTemplateId"/>, or touches a
/// <see cref="TemplateComposition"/> row. Those are exactly the edges the
/// flattener walks to build a chain.
/// </para>
///
/// <para>
/// An entry whose owning id cannot be resolved (a child row deleted before
/// its FK was materialised, or an entity type added later that this switch
/// does not know) sets <c>Unattributed</c>, which conservatively invalidates
/// every cached chain rather than silently letting a stale entry survive.
/// </para>
/// </summary>
private WatermarkBumps CollectWatermarkBumps()
{
var templates = new HashSet<int>();
var structural = new HashSet<int>();
var instances = new HashSet<int>();
var unattributed = false;
foreach (var entry in _context.ChangeTracker.Entries())
{
if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
continue;
switch (entry.Entity)
{
case Template t:
templates.Add(t.Id);
// Add/Delete changes chain membership; a re-parent changes it too.
if (entry.State != EntityState.Modified
|| entry.Property(nameof(Template.ParentTemplateId)).IsModified)
{
structural.Add(t.Id);
}
break;
case TemplateAttribute a:
templates.Add(a.TemplateId);
break;
case TemplateAlarm al:
templates.Add(al.TemplateId);
break;
case TemplateScript s:
templates.Add(s.TemplateId);
break;
case TemplateNativeAlarmSource ns:
templates.Add(ns.TemplateId);
break;
case TemplateComposition c:
templates.Add(c.TemplateId);
structural.Add(c.TemplateId);
break;
case Instance i:
instances.Add(i.Id);
break;
case InstanceAttributeOverride ao:
instances.Add(ao.InstanceId);
break;
case InstanceAlarmOverride alo:
instances.Add(alo.InstanceId);
break;
case InstanceNativeAlarmSourceOverride nso:
instances.Add(nso.InstanceId);
break;
case InstanceConnectionBinding cb:
instances.Add(cb.InstanceId);
break;
case SharedScript:
// Shared scripts are a session-global input to validation, not
// owned by any one template — invalidate everything.
unattributed = true;
break;
default:
// Areas, folders and anything else do not feed the flattener.
break;
}
}
return new WatermarkBumps(templates, structural, instances, unattributed);
}
/// <summary>Applies a previously collected <see cref="WatermarkBumps"/> snapshot.</summary>
private void ApplyWatermarkBumps(WatermarkBumps bumps)
{
if (bumps.Unattributed)
_watermark.BumpAll();
foreach (var templateId in bumps.Templates)
_watermark.BumpTemplate(templateId, bumps.StructuralTemplates.Contains(templateId));
foreach (var instanceId in bumps.Instances)
_watermark.BumpInstance(instanceId);
}
}
@@ -1,10 +1,12 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
@@ -65,6 +67,13 @@ public static class ServiceCollectionExtensions
return new ScadaBridgeDbContext(options, protectionProvider);
});
// Process-wide template/instance version watermark. Singleton because the
// writer (TemplateEngineRepository.SaveChangesAsync, scoped) and the readers
// (the flatten-session cache and the staleness fast path, also scoped) must
// observe the same counters across DI scopes. TryAdd so the DeploymentManager
// registration of the same interface is idempotent.
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddScoped<ISecurityRepository, SecurityRepository>();
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();