using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; 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); } /// public async Task> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default) { return await _dbContext.DeploymentRecords .OrderByDescending(d => d.DeployedAt) .ToListAsync(cancellationToken); } /// 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) { // DeploymentManager-026: 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 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); // CD-017: 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; } // --- WP-8: DeployedConfigSnapshot --- /// public async Task GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default) { return await _dbContext.Set() .FirstOrDefaultAsync(s => s.InstanceId == instanceId, 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. var prior = await _dbContext.Set() .Where(p => p.InstanceId == pending.InstanceId) .ToListAsync(cancellationToken); if (prior.Count > 0) { _dbContext.Set().RemoveRange(prior); } 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. var expired = await _dbContext.Set() .Where(p => p.ExpiresAtUtc <= nowUtc) .ToListAsync(cancellationToken); if (expired.Count == 0) { return 0; } _dbContext.Set().RemoveRange(expired); await _dbContext.SaveChangesAsync(cancellationToken); return expired.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 (the periodic purge is still a // deferred TODO), so 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. var expired = await _dbContext.Set() .Where(p => p.InstanceId == instanceId && p.ExpiresAtUtc <= createdAtUtc) .ToListAsync(cancellationToken); if (expired.Count > 0) { _dbContext.Set().RemoveRange(expired); } // 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: one SaveChanges flushes the expired-row cleanup and, when staged, the new // row together (EF orders the delete before the same-DeploymentId insert to satisfy the // unique index). Returns true only when a fresh row was staged. await _dbContext.SaveChangesAsync(cancellationToken); return !liveExists; } // --- 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); } }