Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/Repositories/DeploymentManagerRepository.cs
T
Joseph Doherty 47f3a703bf perf(search): prefix-by-default Deployments and config-audit search; bounded Contains opt-in (ScadaBridge#50)
Deployments free-text search and the config-audit EntityName filter no longer
emit LIKE '%term%' by default. Prefix (LIKE 'term%') is the default; substring
is an explicit opt-in (DeploymentSearchMode.Contains / entityNameContains)
always bounded by a DeployedAt/Timestamp window (explicit, else 30 days).
Migration PrefixSearchIndexes adds DeployedBy, RevisionHash and EntityName
indexes. Deployments page gains a 'Match anywhere' checkbox.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:42:56 -05:00

912 lines
40 KiB
C#

using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
/// <summary>
/// EF Core implementation of <see cref="IDeploymentManagerRepository"/> covering
/// the deployment pipeline's persistence surface: <c>DeploymentRecord</c> CRUD
/// (with optimistic concurrency via <c>DeploymentRecord.RowVersion</c>),
/// <c>SystemArtifactDeploymentRecord</c> CRUD, <c>DeployedConfigSnapshot</c> CRUD,
/// and a Restrict-FK-aware <see cref="DeleteInstanceAsync"/> that explicitly
/// clears dependent deployment-record rows before removing an instance.
/// </summary>
public class DeploymentManagerRepository : IDeploymentManagerRepository
{
private readonly ScadaBridgeDbContext _dbContext;
/// <summary>
/// Initializes a new instance of the DeploymentManagerRepository class.
/// </summary>
/// <param name="dbContext">The database context for accessing deployment data.</param>
public DeploymentManagerRepository(ScadaBridgeDbContext dbContext)
{
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
}
// --- DeploymentRecord ---
/// <inheritdoc />
public async Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
}
/// <summary>
/// Shared filtered join behind <see cref="QueryDeploymentListPageAsync"/> and
/// <see cref="GetDeploymentStatusCountsAsync"/>, so the two reads that back one
/// screen can never disagree about which rows they describe.
///
/// <para>
/// The join to <c>Instance</c> is an INNER join, which is exact rather than
/// merely convenient: <c>DeploymentRecord</c> has a <c>Restrict</c> FK to
/// <c>Instance</c> and <see cref="DeleteInstanceAsync"/> removes the records
/// before the instance, so an orphaned deployment record cannot exist. The join
/// is what lets site scoping run in SQL — <c>DeploymentRecord</c> itself has no
/// <c>SiteId</c>.
/// </para>
/// </summary>
/// <param name="filter">Filter to apply.</param>
/// <param name="applyStatus">
/// When <see langword="false"/>, the status constraint is skipped — the
/// status-count aggregation needs every status of the otherwise-identical set.
/// </param>
/// <returns>The composed, unordered, untracked query.</returns>
internal IQueryable<DeploymentInstanceJoin> BuildListQuery(DeploymentListFilter filter, bool applyStatus)
{
ArgumentNullException.ThrowIfNull(filter);
var query =
from d in _dbContext.DeploymentRecords.AsNoTracking()
join i in _dbContext.Set<Instance>().AsNoTracking() on d.InstanceId equals i.Id
select new DeploymentInstanceJoin
{
Id = d.Id,
DeploymentId = d.DeploymentId,
InstanceId = d.InstanceId,
InstanceUniqueName = i.UniqueName,
SiteId = i.SiteId,
Status = d.Status,
RevisionHash = d.RevisionHash,
DeployedBy = d.DeployedBy,
DeployedAt = d.DeployedAt,
CompletedAt = d.CompletedAt,
ErrorMessage = d.ErrorMessage
};
if (filter.InstanceId.HasValue)
query = query.Where(r => r.InstanceId == filter.InstanceId.Value);
if (applyStatus && filter.Status.HasValue)
query = query.Where(r => r.Status == filter.Status.Value);
if (filter.SiteIdScope != null)
{
// An EMPTY scope is a real filter — a user permitted no sites sees
// nothing — so it must not be short-circuited into "no filter".
var scope = filter.SiteIdScope as int[] ?? filter.SiteIdScope.ToArray();
query = query.Where(r => scope.Contains(r.SiteId));
}
if (filter.EffectiveDeployedFrom(DateTimeOffset.UtcNow) is { } from)
query = query.Where(r => r.DeployedAt >= from);
if (filter.NormalizedSearch is { } search)
{
// Default: PREFIX match across the four identifiers a deployment is findable
// by (LIKE 'term%', index-seekable; case-insensitivity is the database
// collation's). Contains is the explicit opt-in scan (LIKE '%term%') and is
// always bounded by the DeployedAt window applied above (ScadaBridge#50).
query = filter.SearchMode == DeploymentSearchMode.Contains
? query.Where(r =>
r.InstanceUniqueName.Contains(search)
|| r.DeploymentId.Contains(search)
|| r.DeployedBy.Contains(search)
|| (r.RevisionHash != null && r.RevisionHash.Contains(search)))
: query.Where(r =>
r.InstanceUniqueName.StartsWith(search)
|| r.DeploymentId.StartsWith(search)
|| r.DeployedBy.StartsWith(search)
|| (r.RevisionHash != null && r.RevisionHash.StartsWith(search)));
}
return query;
}
/// <inheritdoc />
public async Task<DeploymentListPage> QueryDeploymentListPageAsync(
DeploymentListFilter filter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default)
{
var page = Math.Max(1, pageNumber);
var size = Math.Clamp(pageSize, 1, DeploymentListPage.MaxPageSize);
var query = BuildListQuery(filter, applyStatus: true);
// Count first: the pager needs a page count, and the caller must be able to
// tell "page 7 is now past the end" (a concurrent purge or a narrowed filter)
// from "page 7 is empty".
var totalCount = await query.CountAsync(cancellationToken);
// DeployedAt ties on rapid redeploys; without the Id tie-break the sort key
// is unstable and offset paging repeats or drops rows between pages.
var rows = await query
.OrderByDescending(r => r.DeployedAt)
.ThenByDescending(r => r.Id)
.Skip((page - 1) * size)
.Take(size)
.Select(r => new DeploymentListRow(
r.Id,
r.DeploymentId,
r.InstanceId,
r.InstanceUniqueName,
r.SiteId,
r.Status,
r.RevisionHash,
r.DeployedBy,
r.DeployedAt,
r.CompletedAt,
r.ErrorMessage))
.ToListAsync(cancellationToken);
return new DeploymentListPage(rows, totalCount);
}
/// <inheritdoc />
public async Task<DeploymentStatusCounts> GetDeploymentStatusCountsAsync(
DeploymentListFilter filter,
CancellationToken cancellationToken = default)
{
// applyStatus: false — the tiles are the status breakdown of the filtered
// set, so the active status filter is the one dimension they must NOT honour.
var grouped = await BuildListQuery(filter, applyStatus: false)
.GroupBy(r => r.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(cancellationToken);
return DeploymentStatusCounts.FromGrouped(grouped.Select(g => (g.Status, g.Count)));
}
/// <inheritdoc />
public async Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
.Where(d => d.InstanceId == instanceId)
.OrderByDescending(d => d.DeployedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default)
{
// Deployments are insert-only (one row per deploy
// attempt), so two records for the same instance can tie on DeployedAt when
// they are created within the same clock tick (a rapid redeploy, or a
// redeploy immediately after a timed-out attempt). SQL Server's choice
// between equal sort keys is undefined, so reconciliation could read the
// wrong "current" record. ThenByDescending(d => d.Id) makes the read
// deterministic — the highest Id (the most recently inserted row) wins.
return await _dbContext.DeploymentRecords
.Where(d => d.InstanceId == instanceId)
.OrderByDescending(d => d.DeployedAt)
.ThenByDescending(d => d.Id)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<int, DeploymentRecord>> GetCurrentDeploymentStatusesAsync(
IReadOnlyCollection<int> instanceIds, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(instanceIds);
if (instanceIds.Count == 0)
return new Dictionary<int, DeploymentRecord>();
var ids = instanceIds.Distinct().ToArray();
// "Current" = no newer record for the same instance, with the same (DeployedAt, Id)
// tie-break GetCurrentDeploymentStatusAsync uses. Resolved in SQL so only one row per
// instance comes back however long each instance's deploy history is.
var records = await _dbContext.DeploymentRecords
.AsNoTracking()
.Where(d => ids.Contains(d.InstanceId))
.Where(d => !_dbContext.DeploymentRecords.Any(o =>
o.InstanceId == d.InstanceId
&& (o.DeployedAt > d.DeployedAt || (o.DeployedAt == d.DeployedAt && o.Id > d.Id))))
.ToListAsync(cancellationToken);
return records.ToDictionary(d => d.InstanceId);
}
/// <inheritdoc />
public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
{
return await _dbContext.DeploymentRecords
.FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken);
}
/// <inheritdoc />
public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
{
await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
{
_dbContext.DeploymentRecords.Update(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(expectedRowVersion);
// DeploymentRecord carries a SQL Server rowversion concurrency token.
// The stub-attach delete path must seed EF's OriginalValues["RowVersion"] with
// the caller's last-observed value so the generated SQL becomes
// `DELETE ... WHERE Id = @id AND RowVersion = @prior`. Without this seeding a
// concurrent edit is silently overwritten; with it, EF raises
// DbUpdateConcurrencyException on SaveChangesAsync — the documented
// optimistic-concurrency contract on deployment status records.
var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
if (record != null)
{
var entry = _dbContext.Entry(record);
entry.OriginalValues["RowVersion"] = expectedRowVersion;
_dbContext.DeploymentRecords.Remove(record);
}
else
{
var stub = new DeploymentRecord("stub", "stub") { Id = id };
_dbContext.DeploymentRecords.Attach(stub);
var entry = _dbContext.Entry(stub);
entry.OriginalValues["RowVersion"] = expectedRowVersion;
_dbContext.DeploymentRecords.Remove(stub);
}
return Task.CompletedTask;
}
// --- SystemArtifactDeploymentRecord ---
/// <inheritdoc />
public async Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default)
{
return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SystemArtifactDeploymentRecords
.OrderByDescending(d => d.DeployedAt)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
{
await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken);
}
/// <inheritdoc />
public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
{
_dbContext.SystemArtifactDeploymentRecords.Update(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default)
{
var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
if (record != null)
{
_dbContext.SystemArtifactDeploymentRecords.Remove(record);
}
else
{
var stub = new SystemArtifactDeploymentRecord("stub", "stub") { Id = id };
_dbContext.SystemArtifactDeploymentRecords.Attach(stub);
_dbContext.SystemArtifactDeploymentRecords.Remove(stub);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<int> PurgeSystemArtifactDeploymentRecordsAsync(
DateTimeOffset cutoffUtc,
int batchSize,
CancellationToken cancellationToken = default)
{
// Same bounded-batch shape as PurgeTerminalDeploymentRecordsAsync: the caller
// re-invokes until fewer than batchSize rows come back. Every row is terminal by
// construction (it is written once, after the artifact sweep finished).
var stale = await _dbContext.SystemArtifactDeploymentRecords
.Where(d => d.DeployedAt < cutoffUtc)
.OrderBy(d => d.Id)
.Take(batchSize)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
_dbContext.SystemArtifactDeploymentRecords.RemoveRange(stale);
await _dbContext.SaveChangesAsync(cancellationToken);
return stale.Count;
}
/// <inheritdoc />
public async Task<IReadOnlyList<ExternalSystemMethod>> GetAllExternalSystemMethodsAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext.Set<ExternalSystemMethod>()
.AsNoTracking()
.OrderBy(m => m.ExternalSystemDefinitionId)
.ThenBy(m => m.Id)
.ToListAsync(cancellationToken);
}
// --- DeployedConfigSnapshot ---
/// <inheritdoc />
public async Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<DeployedConfigSnapshot>()
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<int, string>> GetDeployedRevisionHashesAsync(CancellationToken cancellationToken = default)
{
// Projection only: ConfigurationJson is never materialised, so this stays cheap enough to
// read for the whole fleet in one round trip.
return await _dbContext.Set<DeployedConfigSnapshot>()
.AsNoTracking()
.Select(s => new { s.InstanceId, s.RevisionHash })
.ToDictionaryAsync(s => s.InstanceId, s => s.RevisionHash, cancellationToken);
}
/// <inheritdoc />
public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
{
await _dbContext.Set<DeployedConfigSnapshot>().AddAsync(snapshot, cancellationToken);
}
/// <inheritdoc />
public Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
{
_dbContext.Set<DeployedConfigSnapshot>().Update(snapshot);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default)
{
var snapshot = await _dbContext.Set<DeployedConfigSnapshot>()
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
if (snapshot != null)
{
_dbContext.Set<DeployedConfigSnapshot>().Remove(snapshot);
}
}
// --- Notify-and-fetch: PendingDeployment staging store ---
/// <inheritdoc />
public async Task AddPendingDeploymentAsync(PendingDeployment pending, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pending);
// Supersession: a newer deploy for the same instance replaces any prior pending
// row. Safe because the per-instance operation lock serializes same-instance
// deploys. SaveChanges is deferred to the caller, matching the other Add methods.
//
// Only the prior rows' KEYS are read: each row carries the whole flattened
// ConfigurationJson (tens of KB), and materialising it just to delete it was the
// dominant cost of the supersede. The delete itself stays a tracked Remove (not
// ExecuteDelete) so it still commits atomically with the new row in the caller's
// SaveChanges.
var priorIds = await _dbContext.Set<PendingDeployment>()
.Where(p => p.InstanceId == pending.InstanceId)
.Select(p => p.Id)
.ToListAsync(cancellationToken);
foreach (var id in priorIds)
{
RemovePendingByKey(id);
}
await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
}
/// <inheritdoc />
public Task<PendingDeployment?> GetPendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default)
{
return _dbContext.Set<PendingDeployment>()
.FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken);
}
/// <inheritdoc />
public Task<PendingDeployment?> GetPendingDeploymentByInstanceIdAsync(int instanceId, DateTimeOffset? nowUtc = null, CancellationToken cancellationToken = default)
{
// At most one pending row per instance by design (supersession + stage-if-absent),
// but order deterministically and take the most recent so a hypothetical duplicate
// never makes the read non-deterministic — mirrors GetCurrentDeploymentStatusAsync.
var query = _dbContext.Set<PendingDeployment>()
.Where(p => p.InstanceId == instanceId);
// Expiry-aware: never hand back an EXPIRED row. Pending rows are only TTL-purged, so an
// expired-but-unpurged row would otherwise return a token the config-fetch endpoint 404s
// (it correctly rejects expired rows), leaving the node unhealed.
if (nowUtc.HasValue)
{
var now = nowUtc.Value;
query = query.Where(p => p.ExpiresAtUtc > now);
}
return query
.OrderByDescending(p => p.CreatedAtUtc)
.ThenByDescending(p => p.Id)
.FirstOrDefaultAsync(cancellationToken);
}
/// <inheritdoc />
public async Task DeletePendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default)
{
var row = await _dbContext.Set<PendingDeployment>()
.FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken);
if (row != null)
{
_dbContext.Set<PendingDeployment>().Remove(row);
}
}
/// <inheritdoc />
public async Task<int> PurgeExpiredPendingDeploymentsAsync(DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
{
// Self-contained TTL maintenance op: commits its own delete and returns the count.
// A set-based DELETE — the expired rows' ConfigurationJson is never read.
return await DeletePendingWhereAsync(p => p.ExpiresAtUtc <= nowUtc, cancellationToken);
}
/// <summary>
/// Deletes every pending row matching <paramref name="predicate"/> immediately, without
/// materialising the rows (each carries its full flattened <c>ConfigurationJson</c>).
/// A relational provider issues one set-based <c>DELETE</c>; a non-relational provider
/// (the EF in-memory provider some host-level tests run on) cannot translate
/// <c>ExecuteDelete</c>, so it falls back to load-and-remove with its own save.
/// </summary>
/// <param name="predicate">Rows to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of rows deleted.</returns>
private async Task<int> DeletePendingWhereAsync(
System.Linq.Expressions.Expression<Func<PendingDeployment, bool>> predicate,
CancellationToken cancellationToken)
{
var query = _dbContext.Set<PendingDeployment>().Where(predicate);
if (_dbContext.Database.IsRelational())
{
return await query.ExecuteDeleteAsync(cancellationToken);
}
var rows = await query.ToListAsync(cancellationToken);
if (rows.Count == 0)
{
return 0;
}
_dbContext.Set<PendingDeployment>().RemoveRange(rows);
await _dbContext.SaveChangesAsync(cancellationToken);
return rows.Count;
}
/// <summary>
/// Marks the pending row with primary key <paramref name="id"/> for deletion on the next
/// <c>SaveChanges</c> without loading it: an already-tracked instance is removed in place,
/// otherwise a key-only stub is attached and removed (EF deletes by key).
/// </summary>
/// <param name="id">Primary key of the pending row.</param>
private void RemovePendingByKey(int id)
{
var set = _dbContext.Set<PendingDeployment>();
var tracked = set.Local.FirstOrDefault(p => p.Id == id);
if (tracked != null)
{
set.Remove(tracked);
return;
}
var stub = new PendingDeployment(
string.Empty, 0, string.Empty, string.Empty, string.Empty,
default, default) { Id = id };
set.Attach(stub);
set.Remove(stub);
}
/// <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 />
public async Task<IReadOnlyList<ExpectedDeployment>> GetExpectedDeploymentsForSiteAsync(int siteId, CancellationToken cancellationToken = default)
{
// Inner join: only instances that have a DeployedConfigSnapshot are returned.
// ConfigurationJson is intentionally excluded — the caller fetches the full config
// on demand via the HTTP endpoint for gap instances only.
return await (
from snap in _dbContext.Set<DeployedConfigSnapshot>()
join inst in _dbContext.Set<Instance>() on snap.InstanceId equals inst.Id
where inst.SiteId == siteId
select new ExpectedDeployment(
inst.Id,
inst.UniqueName,
snap.RevisionHash,
snap.DeploymentId,
inst.State == InstanceState.Enabled))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task<bool> StagePendingIfAbsentAsync(
int instanceId, string deploymentId, string revisionHash,
string configurationJson, string token,
DateTimeOffset createdAtUtc, DateTimeOffset expiresAtUtc,
CancellationToken cancellationToken = default)
{
// Treat createdAtUtc as "now". FIRST remove any EXPIRED pending rows for this instance
// (ExpiresAtUtc <= now). Pending rows are only TTL-purged — PendingDeploymentPurgeActor
// sweeps them on its PendingDeploymentPurgeInterval cadence — so between sweeps an
// EXPIRED-but-unpurged row would otherwise (a) read as "a deploy is in flight" and block
// staging — handing the node an expired token (HTTP 404) and leaving it unhealed — and
// (b) collide on the DeploymentId UNIQUE index when a reconcile re-stages the snapshot's
// own DeploymentId. Dropping expired rows first fixes both. The delete is set-based and
// immediate (an expired row is dead either way), so its JSON is never read.
await DeletePendingWhereAsync(
p => p.InstanceId == instanceId && p.ExpiresAtUtc <= createdAtUtc, cancellationToken);
// THEN insert-if-absent against still-LIVE rows only. A live pending row means a genuine
// in-flight deploy (or a concurrent reconcile) already owns the slot — do NOT supersede it;
// clobbering it could make the node fetch the reconcile token while the original deliver is
// mid-flight. (Expired rows just removed are disjoint from this future-expiry predicate.)
var liveExists = await _dbContext.Set<PendingDeployment>()
.AnyAsync(p => p.InstanceId == instanceId && p.ExpiresAtUtc > createdAtUtc, cancellationToken);
if (!liveExists)
{
var pending = new PendingDeployment(
deploymentId, instanceId, revisionHash,
configurationJson, token, createdAtUtc, expiresAtUtc);
await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
}
// Self-contained: the expired-row cleanup has already run, so the same-DeploymentId insert
// below cannot collide with it on the unique index. Returns true only when a fresh row was
// staged.
await _dbContext.SaveChangesAsync(cancellationToken);
return !liveExists;
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<int, DeployedConfigSnapshot>> GetDeployedSnapshotsByInstanceIdsAsync(
IReadOnlyCollection<int> instanceIds, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(instanceIds);
if (instanceIds.Count == 0)
return new Dictionary<int, DeployedConfigSnapshot>();
var ids = instanceIds.Distinct().ToArray();
return await _dbContext.Set<DeployedConfigSnapshot>()
.AsNoTracking()
.Where(s => ids.Contains(s.InstanceId))
.ToDictionaryAsync(s => s.InstanceId, cancellationToken);
}
/// <inheritdoc />
public async Task<IReadOnlyDictionary<int, PendingDeployment>> StagePendingBatchIfAbsentAsync(
IReadOnlyList<PendingDeployment> candidates, DateTimeOffset nowUtc,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(candidates);
var byInstance = new Dictionary<int, PendingDeployment>();
foreach (var candidate in candidates)
byInstance.TryAdd(candidate.InstanceId, candidate);
if (byInstance.Count == 0)
return byInstance;
var ids = byInstance.Keys.ToArray();
// Same two-step contract as StagePendingIfAbsentAsync, set-based: drop EXPIRED rows
// first (they would otherwise read as in-flight and collide on the DeploymentId unique
// index when a snapshot's own DeploymentId is re-staged), then insert only where no
// LIVE row exists. A live row is never superseded.
await DeletePendingWhereAsync(
p => ids.Contains(p.InstanceId) && p.ExpiresAtUtc <= nowUtc, cancellationToken);
var live = await ReadLivePendingAsync(ids, nowUtc, cancellationToken);
var added = new List<PendingDeployment>();
foreach (var (instanceId, candidate) in byInstance)
{
if (live.ContainsKey(instanceId))
continue;
added.Add(candidate);
}
if (added.Count == 0)
return live;
await _dbContext.Set<PendingDeployment>().AddRangeAsync(added, cancellationToken);
try
{
await _dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// The batch lost a race — most commonly the peer site node's concurrent reconcile
// inserted the same snapshot DeploymentId first (unique index), or an instance was
// deleted mid-reconcile (FK). Un-track the failed inserts and fall back to staging
// one row at a time, so one bad row costs only itself and a row the peer staged is
// picked up as the live row it now is.
foreach (var row in added)
_dbContext.Entry(row).State = EntityState.Detached;
return await StagePendingOneByOneAsync(byInstance, nowUtc, cancellationToken);
}
foreach (var row in added)
live[row.InstanceId] = row;
return live;
}
/// <summary>
/// Fallback for <see cref="StagePendingBatchIfAbsentAsync"/> after its single save failed:
/// stages each candidate through <see cref="StagePendingIfAbsentAsync"/>, skipping (and
/// omitting from the result) any single row that still cannot be written.
/// </summary>
private async Task<Dictionary<int, PendingDeployment>> StagePendingOneByOneAsync(
IReadOnlyDictionary<int, PendingDeployment> byInstance, DateTimeOffset nowUtc,
CancellationToken cancellationToken)
{
var result = new Dictionary<int, PendingDeployment>();
foreach (var (instanceId, candidate) in byInstance)
{
bool staged;
try
{
staged = await StagePendingIfAbsentAsync(
instanceId, candidate.DeploymentId, candidate.RevisionHash,
candidate.ConfigurationJson, candidate.Token,
nowUtc, candidate.ExpiresAtUtc, cancellationToken);
}
catch (DbUpdateException)
{
DetachPendingChanges();
continue;
}
if (staged)
{
result[instanceId] = candidate;
continue;
}
var existing = await ReadLivePendingAsync([instanceId], nowUtc, cancellationToken);
if (existing.TryGetValue(instanceId, out var row))
result[instanceId] = row;
}
return result;
}
/// <summary>
/// Reads the most recent LIVE pending row per instance, projected WITHOUT
/// <c>ConfigurationJson</c> (the caller only needs the fetch coordinates), untracked.
/// </summary>
private async Task<Dictionary<int, PendingDeployment>> ReadLivePendingAsync(
int[] instanceIds, DateTimeOffset nowUtc, CancellationToken cancellationToken)
{
var rows = await _dbContext.Set<PendingDeployment>()
.AsNoTracking()
.Where(p => instanceIds.Contains(p.InstanceId) && p.ExpiresAtUtc > nowUtc)
.Select(p => new
{
p.Id, p.DeploymentId, p.InstanceId, p.RevisionHash, p.Token, p.CreatedAtUtc, p.ExpiresAtUtc
})
.ToListAsync(cancellationToken);
// At most one live row per instance by design; on a hypothetical duplicate take the
// most recent, mirroring GetPendingDeploymentByInstanceIdAsync's ordering.
return rows
.GroupBy(r => r.InstanceId)
.Select(g => g.OrderByDescending(r => r.CreatedAtUtc).ThenByDescending(r => r.Id).First())
.ToDictionary(
r => r.InstanceId,
r => new PendingDeployment(
r.DeploymentId, r.InstanceId, r.RevisionHash, string.Empty, r.Token,
r.CreatedAtUtc, r.ExpiresAtUtc) { Id = r.Id });
}
/// <inheritdoc />
public async Task<IReadOnlyList<Instance>> GetDeployedInstanceSummariesAsync(
CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
.AsNoTracking()
.Where(i => i.State != InstanceState.NotDeployed)
.Select(i => new Instance(i.UniqueName)
{
Id = i.Id,
SiteId = i.SiteId,
TemplateId = i.TemplateId,
State = i.State
})
.ToListAsync(cancellationToken);
}
// --- Instance lookups for deployment pipeline ---
/// <inheritdoc />
public async Task<Instance?> GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.AsSplitQuery()
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
}
/// <inheritdoc />
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
{
return await _dbContext.Set<Instance>()
.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 Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
{
_dbContext.Set<Instance>().Update(instance);
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default)
{
// DeploymentRecords have a Restrict FK to Instance — remove them
// explicitly first. The snapshot, overrides, and connection bindings
// are configured with cascade delete and go with the instance.
var records = await _dbContext.DeploymentRecords
.Where(d => d.InstanceId == instanceId)
.ToListAsync(cancellationToken);
if (records.Count > 0)
{
_dbContext.DeploymentRecords.RemoveRange(records);
}
var instance = await _dbContext.Set<Instance>()
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
if (instance != null)
{
_dbContext.Set<Instance>().Remove(instance);
}
}
/// <inheritdoc />
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public int DetachPendingChanges()
{
// ChangeTracker.Entries() is enumerated into a list FIRST: setting State
// mutates the tracker, and mutating it while enumerating it throws.
// Unchanged/Detached entries are left alone — they carry no pending write,
// so keeping them tracked preserves identity resolution for anything the
// caller still holds a reference to.
var pending = _dbContext.ChangeTracker.Entries()
.Where(e => e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.ToList();
foreach (var entry in pending)
entry.State = EntityState.Detached;
return pending.Count;
}
/// <inheritdoc />
public int DetachSettledDeploymentRows(int instanceId)
{
// Enumerated into a list first: changing State mutates the tracker.
var settled = _dbContext.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Unchanged
&& e.Entity switch
{
PendingDeployment p => p.InstanceId == instanceId,
DeployedConfigSnapshot s => s.InstanceId == instanceId,
DeploymentRecord r => r.InstanceId == instanceId,
_ => false
})
.ToList();
foreach (var entry in settled)
entry.State = EntityState.Detached;
return settled.Count;
}
/// <summary>
/// Flat shape of the deployment-record ⋈ instance join used to compose the list
/// query. A named type rather than an anonymous one so
/// <see cref="BuildListQuery"/> can hand the composed-but-unexecuted query back
/// to both the paging read and the count aggregation, keeping the two in
/// lockstep. Not a public contract — <see cref="DeploymentListRow"/> is what
/// leaves this class.
/// </summary>
internal sealed class DeploymentInstanceJoin
{
/// <summary>Deployment record primary key.</summary>
public int Id { get; init; }
/// <summary>Unique deployment identifier.</summary>
public string DeploymentId { get; init; } = string.Empty;
/// <summary>Primary key of the target instance.</summary>
public int InstanceId { get; init; }
/// <summary>Unique name of the target instance.</summary>
public string InstanceUniqueName { get; init; } = string.Empty;
/// <summary>Primary key of the instance's site.</summary>
public int SiteId { get; init; }
/// <summary>Current deployment status.</summary>
public DeploymentStatus Status { get; init; }
/// <summary>Revision hash of the deployed configuration, if any.</summary>
public string? RevisionHash { get; init; }
/// <summary>User who initiated the deployment.</summary>
public string DeployedBy { get; init; } = string.Empty;
/// <summary>When the deployment was initiated.</summary>
public DateTimeOffset DeployedAt { get; init; }
/// <summary>When the deployment completed, if it has.</summary>
public DateTimeOffset? CompletedAt { get; init; }
/// <summary>Failure message, if the deployment failed.</summary>
public string? ErrorMessage { get; init; }
}
}