Merge branch 'worktree-agent-a0098b40576d74cfd' into arch-review-remediation
This commit is contained in:
+83
@@ -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 />
|
||||
|
||||
+224
-2
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user