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; /// /// EF Core implementation of covering /// the deployment pipeline's persistence surface: DeploymentRecord CRUD /// (with optimistic concurrency via DeploymentRecord.RowVersion), /// SystemArtifactDeploymentRecord CRUD, DeployedConfigSnapshot CRUD, /// and a Restrict-FK-aware that explicitly /// clears dependent deployment-record rows before removing an instance. /// public class DeploymentManagerRepository : IDeploymentManagerRepository { private readonly ScadaBridgeDbContext _dbContext; /// /// Initializes a new instance of the DeploymentManagerRepository class. /// /// The database context for accessing deployment data. public DeploymentManagerRepository(ScadaBridgeDbContext dbContext) { _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); } // --- DeploymentRecord --- /// public async Task GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default) { return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken); } /// /// Shared filtered join behind and /// , so the two reads that back one /// screen can never disagree about which rows they describe. /// /// /// The join to Instance is an INNER join, which is exact rather than /// merely convenient: DeploymentRecord has a Restrict FK to /// Instance and removes the records /// before the instance, so an orphaned deployment record cannot exist. The join /// is what lets site scoping run in SQL — DeploymentRecord itself has no /// SiteId. /// /// /// Filter to apply. /// /// When , the status constraint is skipped — the /// status-count aggregation needs every status of the otherwise-identical set. /// /// The composed, unordered, untracked query. internal IQueryable BuildListQuery(DeploymentListFilter filter, bool applyStatus) { ArgumentNullException.ThrowIfNull(filter); var query = from d in _dbContext.DeploymentRecords.AsNoTracking() join i in _dbContext.Set().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; } /// public async Task 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); } /// public async Task 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))); } /// public async Task> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) { return await _dbContext.DeploymentRecords .Where(d => d.InstanceId == instanceId) .OrderByDescending(d => d.DeployedAt) .ToListAsync(cancellationToken); } /// public async Task 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); } /// public async Task> GetCurrentDeploymentStatusesAsync( IReadOnlyCollection instanceIds, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(instanceIds); if (instanceIds.Count == 0) return new Dictionary(); 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); } /// public async Task GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default) { return await _dbContext.DeploymentRecords .FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken); } /// public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default) { await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken); } /// public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default) { _dbContext.DeploymentRecords.Update(record); return Task.CompletedTask; } /// 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 --- /// public async Task GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default) { return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken); } /// public async Task> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default) { return await _dbContext.SystemArtifactDeploymentRecords .OrderByDescending(d => d.DeployedAt) .ToListAsync(cancellationToken); } /// public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default) { await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken); } /// public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default) { _dbContext.SystemArtifactDeploymentRecords.Update(record); return Task.CompletedTask; } /// 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; } /// public async Task 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; } /// public async Task> GetAllExternalSystemMethodsAsync( CancellationToken cancellationToken = default) { return await _dbContext.Set() .AsNoTracking() .OrderBy(m => m.ExternalSystemDefinitionId) .ThenBy(m => m.Id) .ToListAsync(cancellationToken); } // --- DeployedConfigSnapshot --- /// public async Task GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) { return await _dbContext.Set() .FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken); } /// public async Task> 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() .AsNoTracking() .Select(s => new { s.InstanceId, s.RevisionHash }) .ToDictionaryAsync(s => s.InstanceId, s => s.RevisionHash, cancellationToken); } /// public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default) { await _dbContext.Set().AddAsync(snapshot, cancellationToken); } /// public Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default) { _dbContext.Set().Update(snapshot); return Task.CompletedTask; } /// public async Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default) { var snapshot = await _dbContext.Set() .FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken); if (snapshot != null) { _dbContext.Set().Remove(snapshot); } } // --- Notify-and-fetch: PendingDeployment staging store --- /// 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() .Where(p => p.InstanceId == pending.InstanceId) .Select(p => p.Id) .ToListAsync(cancellationToken); foreach (var id in priorIds) { RemovePendingByKey(id); } await _dbContext.Set().AddAsync(pending, cancellationToken); } /// public Task GetPendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default) { return _dbContext.Set() .FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken); } /// public Task 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() .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); } /// public async Task DeletePendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default) { var row = await _dbContext.Set() .FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken); if (row != null) { _dbContext.Set().Remove(row); } } /// public async Task 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); } /// /// Deletes every pending row matching immediately, without /// materialising the rows (each carries its full flattened ConfigurationJson). /// A relational provider issues one set-based DELETE; a non-relational provider /// (the EF in-memory provider some host-level tests run on) cannot translate /// ExecuteDelete, so it falls back to load-and-remove with its own save. /// /// Rows to delete. /// Cancellation token. /// The number of rows deleted. private async Task DeletePendingWhereAsync( System.Linq.Expressions.Expression> predicate, CancellationToken cancellationToken) { var query = _dbContext.Set().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().RemoveRange(rows); await _dbContext.SaveChangesAsync(cancellationToken); return rows.Count; } /// /// Marks the pending row with primary key for deletion on the next /// SaveChanges without loading it: an already-tracked instance is removed in place, /// otherwise a key-only stub is attached and removed (EF deletes by key). /// /// Primary key of the pending row. private void RemovePendingByKey(int id) { var set = _dbContext.Set(); 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); } /// public async Task 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 --- /// public async Task> 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() join inst in _dbContext.Set() 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); } /// public async Task 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() .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().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; } /// public async Task> GetDeployedSnapshotsByInstanceIdsAsync( IReadOnlyCollection instanceIds, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(instanceIds); if (instanceIds.Count == 0) return new Dictionary(); var ids = instanceIds.Distinct().ToArray(); return await _dbContext.Set() .AsNoTracking() .Where(s => ids.Contains(s.InstanceId)) .ToDictionaryAsync(s => s.InstanceId, cancellationToken); } /// public async Task> StagePendingBatchIfAbsentAsync( IReadOnlyList candidates, DateTimeOffset nowUtc, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(candidates); var byInstance = new Dictionary(); 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(); foreach (var (instanceId, candidate) in byInstance) { if (live.ContainsKey(instanceId)) continue; added.Add(candidate); } if (added.Count == 0) return live; await _dbContext.Set().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; } /// /// Fallback for after its single save failed: /// stages each candidate through , skipping (and /// omitting from the result) any single row that still cannot be written. /// private async Task> StagePendingOneByOneAsync( IReadOnlyDictionary byInstance, DateTimeOffset nowUtc, CancellationToken cancellationToken) { var result = new Dictionary(); 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; } /// /// Reads the most recent LIVE pending row per instance, projected WITHOUT /// ConfigurationJson (the caller only needs the fetch coordinates), untracked. /// private async Task> ReadLivePendingAsync( int[] instanceIds, DateTimeOffset nowUtc, CancellationToken cancellationToken) { var rows = await _dbContext.Set() .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 }); } /// public async Task> GetDeployedInstanceSummariesAsync( CancellationToken cancellationToken = default) { return await _dbContext.Set() .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 --- /// public async Task GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default) { return await _dbContext.Set() .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) .Include(i => i.NativeAlarmSourceOverrides) .AsSplitQuery() .FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken); } /// public async Task GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default) { return await _dbContext.Set() .Include(i => i.AttributeOverrides) .Include(i => i.AlarmOverrides) .Include(i => i.ConnectionBindings) .Include(i => i.NativeAlarmSourceOverrides) .AsSplitQuery() .FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken); } /// public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default) { _dbContext.Set().Update(instance); return Task.CompletedTask; } /// 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() .FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken); if (instance != null) { _dbContext.Set().Remove(instance); } } /// public async Task SaveChangesAsync(CancellationToken cancellationToken = default) { return await _dbContext.SaveChangesAsync(cancellationToken); } /// 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; } /// 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; } /// /// Flat shape of the deployment-record ⋈ instance join used to compose the list /// query. A named type rather than an anonymous one so /// 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 — is what /// leaves this class. /// internal sealed class DeploymentInstanceJoin { /// Deployment record primary key. public int Id { get; init; } /// Unique deployment identifier. public string DeploymentId { get; init; } = string.Empty; /// Primary key of the target instance. public int InstanceId { get; init; } /// Unique name of the target instance. public string InstanceUniqueName { get; init; } = string.Empty; /// Primary key of the instance's site. public int SiteId { get; init; } /// Current deployment status. public DeploymentStatus Status { get; init; } /// Revision hash of the deployed configuration, if any. public string? RevisionHash { get; init; } /// User who initiated the deployment. public string DeployedBy { get; init; } = string.Empty; /// When the deployment was initiated. public DateTimeOffset DeployedAt { get; init; } /// When the deployment completed, if it has. public DateTimeOffset? CompletedAt { get; init; } /// Failure message, if the deployment failed. public string? ErrorMessage { get; init; } } }