e0e4b24679
Six adversarial-review findings, each verified against the code first.
F1 (HIGH) CLI HttpClient capped every call at min(30s, caller timeout),
silently truncating deploy site's 5-minute BulkDeployTimeout and the
5-minute bundle export/preview/import calls — which printed a fake
"504 Request timed out" while the server kept working. HttpClient.Timeout
is now Timeout.InfiniteTimeSpan (the per-call CTS is the single overall
deadline, connect included) with the connect phase bounded separately on
SocketsHttpHandler.ConnectTimeout. The env override is renamed to
SCADABRIDGE_HTTP_CONNECT_TIMEOUT_SECONDS to match its new meaning.
F2 (HIGH) StaleInstanceProbe's process-static memo served stale hashes
because nothing bumped the watermark on three paths:
(a) BundleImporter commits through the raw DbContext, so no import ever
moved the watermark — a second import overwriting the same template
could be OMITTED from ImportResult.StaleInstanceIds. It now bumps
once per apply ATTEMPT: after the commit, and after the rollback too
(the probe runs pre-commit, so a rolled-back attempt leaves memos for
state that never landed; bumping on both paths is the simplest
correct shape, versus threading transaction awareness through a
process-static cache).
(b) CollectWatermarkBumps' default: arm silently no-op'd, contradicting
its own doc. It now sets unattributed=true — an over-broad bump costs
extra work, a missed one produces stale work.
(c) DataConnection edits route through SiteRepository.SaveChangesAsync,
which had no watermark at all, yet Protocol/Primary+Backup config/
FailoverRetryCount are revision-hash inputs. It now bumps (BumpAll —
a connection has no owning template) after a commit that touched one.
F3 (MED) CLI TemplateTableProjection read child ARRAYS, but ListTemplates
now returns database-projected TemplateSummary rows, so template list
printed all zeros. It now prefers the *Count scalars and falls back to
array length (template get still returns full entities). --detail help
text and README corrected: a listing cannot yield definitions, so --detail
renders the raw summary payload and template get --id is the full dump.
F4 (MED) DeploySiteAsync staged every PendingDeployment in phase 1 against
a 5-min TTL while phase 2 reached them one batch at a time, so tail
instances' fetch tokens could expire before their command was sent.
Staging moved into phase 2, immediately before each send; prepare keeps
its flatten/validate/record work. The staging write is the phase's only
repository touch and is serialised behind a 1-permit semaphore, so the
non-thread-safe DbContext constraint holds and the sends stay concurrent.
F5 (MED, latent) DeploySiteAsync leaked every held operation lock if
cancelled — a wedged per-instance semaphore is permanent for the process.
Phase 2 no longer throws (cancellation is recorded as a per-instance
outcome so phase 3 still runs), and an escape from phase 1 or 3 now
unwinds every unfinalised entry: Failed status + lock release.
F6 (LOW) ScriptCompileVerdictCache's promotion wrote hot directly,
bypassing SegmentCapacity (true ceiling 3x against a documented 2x).
Promotion now goes through Store, keeping generational semantics; _hot
and _cold are volatile.
Tests: CLI 396, DeploymentManager 133, ManagementService 494,
TemplateEngine 478, ScriptAnalysis 60, Transport 157, Transport
integration 106, ConfigurationDatabase 366 — all green, 0 build warnings.
The F2/F4/F5 regression tests were each confirmed to FAIL with their fix
reverted.
900 lines
35 KiB
C#
900 lines
35 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
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>
|
|
/// <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
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Template?> GetTemplateByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Templates
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.Include(t => t.Compositions)
|
|
.Include(t => t.NativeAlarmSources)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Template?> GetTemplateWithChildrenAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await GetTemplateByIdAsync(id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Template>> GetTemplatesWithChildrenAsync(
|
|
IEnumerable<string> names, CancellationToken cancellationToken = default)
|
|
{
|
|
// Bulk lookup replaces the per-name N+1 in
|
|
// BundleImporter.PreviewAsync. Filter out null / empty / duplicate
|
|
// names before the query so EF emits a clean, deduplicated IN clause.
|
|
if (names is null) return Array.Empty<Template>();
|
|
var distinct = names
|
|
.Where(n => !string.IsNullOrEmpty(n))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToArray();
|
|
if (distinct.Length == 0) return Array.Empty<Template>();
|
|
|
|
return await _context.Templates
|
|
.Where(t => distinct.Contains(t.Name))
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.Include(t => t.Compositions)
|
|
.Include(t => t.NativeAlarmSources)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Templates
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Alarms)
|
|
.Include(t => t.Scripts)
|
|
.Include(t => t.Compositions)
|
|
.Include(t => t.NativeAlarmSources)
|
|
.AsSplitQuery()
|
|
.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)
|
|
{
|
|
return await _context.Templates
|
|
.Where(t => t.Compositions.Any(c => c.ComposedTemplateId == composedTemplateId))
|
|
.Include(t => t.Attributes)
|
|
.Include(t => t.Scripts)
|
|
.Include(t => t.Compositions)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.Templates.AddAsync(template, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateAsync(Template template, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.Templates.Update(template);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var template = await _context.Templates.FindAsync(new object[] { id }, cancellationToken);
|
|
if (template != null)
|
|
{
|
|
_context.Templates.Remove(template);
|
|
}
|
|
}
|
|
|
|
// TemplateAttribute
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateAttribute?> GetTemplateAttributeByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateAttribute>> GetAttributesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAttributes
|
|
.Where(a => a.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateAttributes.AddAsync(attribute, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateAttributeAsync(TemplateAttribute attribute, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateAttributes.Update(attribute);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateAttributeAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var attribute = await _context.TemplateAttributes.FindAsync(new object[] { id }, cancellationToken);
|
|
if (attribute != null)
|
|
{
|
|
_context.TemplateAttributes.Remove(attribute);
|
|
}
|
|
}
|
|
|
|
// TemplateAlarm
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateAlarm?> GetTemplateAlarmByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateAlarm>> GetAlarmsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateAlarms
|
|
.Where(a => a.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateAlarms.AddAsync(alarm, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateAlarmAsync(TemplateAlarm alarm, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateAlarms.Update(alarm);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateAlarmAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var alarm = await _context.TemplateAlarms.FindAsync(new object[] { id }, cancellationToken);
|
|
if (alarm != null)
|
|
{
|
|
_context.TemplateAlarms.Remove(alarm);
|
|
}
|
|
}
|
|
|
|
// TemplateNativeAlarmSource
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateNativeAlarmSource?> GetTemplateNativeAlarmSourceByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateNativeAlarmSources.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateNativeAlarmSource>> GetNativeAlarmSourcesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateNativeAlarmSources
|
|
.Where(s => s.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateNativeAlarmSources.AddAsync(source, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateNativeAlarmSourceAsync(TemplateNativeAlarmSource source, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateNativeAlarmSources.Update(source);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateNativeAlarmSourceAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var source = await _context.TemplateNativeAlarmSources.FindAsync(new object[] { id }, cancellationToken);
|
|
if (source != null)
|
|
{
|
|
_context.TemplateNativeAlarmSources.Remove(source);
|
|
}
|
|
}
|
|
|
|
// TemplateScript
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateScript?> GetTemplateScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateScript>> GetScriptsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateScripts
|
|
.Where(s => s.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateScripts.AddAsync(script, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateScriptAsync(TemplateScript script, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateScripts.Update(script);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateScriptAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var script = await _context.TemplateScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
if (script != null)
|
|
{
|
|
_context.TemplateScripts.Remove(script);
|
|
}
|
|
}
|
|
|
|
// TemplateComposition
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateComposition?> GetTemplateCompositionByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateComposition>> GetCompositionsByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.TemplateCompositions
|
|
.Where(c => c.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.TemplateCompositions.AddAsync(composition, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateTemplateCompositionAsync(TemplateComposition composition, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateCompositions.Update(composition);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteTemplateCompositionAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var composition = await _context.TemplateCompositions.FindAsync(new object[] { id }, cancellationToken);
|
|
if (composition != null)
|
|
{
|
|
_context.TemplateCompositions.Remove(composition);
|
|
}
|
|
}
|
|
|
|
// Instance
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Instance?> GetInstanceByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(i => i.Id == id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetAllInstancesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetInstancesByTemplateIdAsync(int templateId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Where(i => i.TemplateId == templateId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Instance>> GetInstancesBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Where(i => i.SiteId == siteId)
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Instances
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.Instances.AddAsync(instance, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.Instances.Update(instance);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var instance = await _context.Instances.FindAsync(new object[] { id }, cancellationToken);
|
|
if (instance != null)
|
|
{
|
|
_context.Instances.Remove(instance);
|
|
}
|
|
}
|
|
|
|
// InstanceAttributeOverride
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceAttributeOverride>> GetOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceAttributeOverrides
|
|
.Where(o => o.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceAttributeOverrides.AddAsync(attributeOverride, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceAttributeOverrideAsync(InstanceAttributeOverride attributeOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceAttributeOverrides.Update(attributeOverride);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceAttributeOverrideAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var attributeOverride = await _context.InstanceAttributeOverrides.FindAsync(new object[] { id }, cancellationToken);
|
|
if (attributeOverride != null)
|
|
{
|
|
_context.InstanceAttributeOverrides.Remove(attributeOverride);
|
|
}
|
|
}
|
|
|
|
// InstanceAlarmOverride
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceAlarmOverride>> GetAlarmOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceAlarmOverrides
|
|
.Where(o => o.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<InstanceAlarmOverride?> GetAlarmOverrideAsync(int instanceId, string alarmCanonicalName, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceAlarmOverrides
|
|
.FirstOrDefaultAsync(
|
|
o => o.InstanceId == instanceId && o.AlarmCanonicalName == alarmCanonicalName,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceAlarmOverrides.AddAsync(alarmOverride, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceAlarmOverrideAsync(InstanceAlarmOverride alarmOverride, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceAlarmOverrides.Update(alarmOverride);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceAlarmOverrideAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var alarmOverride = await _context.InstanceAlarmOverrides.FindAsync(new object[] { id }, cancellationToken);
|
|
if (alarmOverride != null)
|
|
{
|
|
_context.InstanceAlarmOverrides.Remove(alarmOverride);
|
|
}
|
|
}
|
|
|
|
// InstanceNativeAlarmSourceOverride
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceNativeAlarmSourceOverride>> GetNativeAlarmSourceOverridesByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceNativeAlarmSourceOverrides
|
|
.Where(o => o.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<InstanceNativeAlarmSourceOverride?> GetNativeAlarmSourceOverrideAsync(int instanceId, string sourceCanonicalName, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceNativeAlarmSourceOverrides
|
|
.FirstOrDefaultAsync(o => o.InstanceId == instanceId && o.SourceCanonicalName == sourceCanonicalName, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceNativeAlarmSourceOverrides.AddAsync(ovr, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceNativeAlarmSourceOverrideAsync(InstanceNativeAlarmSourceOverride ovr, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceNativeAlarmSourceOverrides.Update(ovr);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceNativeAlarmSourceOverrideAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var ovr = await _context.InstanceNativeAlarmSourceOverrides.FindAsync(new object[] { id }, cancellationToken);
|
|
if (ovr != null)
|
|
{
|
|
_context.InstanceNativeAlarmSourceOverrides.Remove(ovr);
|
|
}
|
|
}
|
|
|
|
// InstanceConnectionBinding
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<InstanceConnectionBinding>> GetBindingsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.InstanceConnectionBindings
|
|
.Where(b => b.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.InstanceConnectionBindings.AddAsync(binding, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceConnectionBindingAsync(InstanceConnectionBinding binding, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.InstanceConnectionBindings.Update(binding);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceConnectionBindingAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var binding = await _context.InstanceConnectionBindings.FindAsync(new object[] { id }, cancellationToken);
|
|
if (binding != null)
|
|
{
|
|
_context.InstanceConnectionBindings.Remove(binding);
|
|
}
|
|
}
|
|
|
|
// Area
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Area?> GetAreaByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Areas
|
|
.Include(a => a.Children)
|
|
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<Area>> GetAreasBySiteIdAsync(int siteId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.Areas
|
|
.Where(a => a.SiteId == siteId)
|
|
.Include(a => a.Children)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddAreaAsync(Area area, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.Areas.AddAsync(area, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateAreaAsync(Area area, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.Areas.Update(area);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteAreaAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var area = await _context.Areas.FindAsync(new object[] { id }, cancellationToken);
|
|
if (area != null)
|
|
{
|
|
_context.Areas.Remove(area);
|
|
}
|
|
}
|
|
|
|
// SharedScript
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SharedScript?> GetSharedScriptByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SharedScript?> GetSharedScriptByNameAsync(string name, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.SharedScripts
|
|
.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<SharedScript>> GetAllSharedScriptsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _context.SharedScripts.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
|
{
|
|
await _context.SharedScripts.AddAsync(sharedScript, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateSharedScriptAsync(SharedScript sharedScript, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.SharedScripts.Update(sharedScript);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteSharedScriptAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var sharedScript = await _context.SharedScripts.FindAsync(new object[] { id }, cancellationToken);
|
|
if (sharedScript != null)
|
|
{
|
|
_context.SharedScripts.Remove(sharedScript);
|
|
}
|
|
}
|
|
|
|
// TemplateFolder
|
|
|
|
/// <inheritdoc />
|
|
public async Task<TemplateFolder?> GetFolderByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
=> await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<TemplateFolder>> GetAllFoldersAsync(CancellationToken cancellationToken = default)
|
|
=> await _context.TemplateFolders
|
|
.OrderBy(f => f.SortOrder)
|
|
.ThenBy(f => f.Name)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
|
|
=> await _context.TemplateFolders.AddAsync(folder, cancellationToken);
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateFolderAsync(TemplateFolder folder, CancellationToken cancellationToken = default)
|
|
{
|
|
_context.TemplateFolders.Update(folder);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteFolderAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var folder = await _context.TemplateFolders.FindAsync(new object[] { id }, cancellationToken);
|
|
if (folder != null)
|
|
{
|
|
_context.TemplateFolders.Remove(folder);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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:
|
|
// Anything this switch does not recognise is invalidated
|
|
// conservatively, as the doc comment above promises. The
|
|
// previous "areas, folders and anything else do not feed the
|
|
// flattener" no-op was wrong in at least one shipped case:
|
|
// DataConnection rows land here, and their Protocol /
|
|
// Primary+Backup configuration / FailoverRetryCount ARE hash
|
|
// inputs (FlatteningService packages them as ConnectionConfig,
|
|
// RevisionHashService hashes them as HashableConnection). A
|
|
// silent no-op on an unrecognised entity is the one failure
|
|
// mode this watermark must not have: it produces STALE work,
|
|
// whereas an over-broad bump only ever produces extra work.
|
|
unattributed = true;
|
|
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);
|
|
}
|
|
}
|