feat(reconcile): site-reconcile messages + expected-set/stage-if-absent repo
- Commons: ReconcileSiteRequest / ReconcileSiteResponse / ReconcileGapItem message contracts (site→central ClusterClient on startup; central reply with gap fetch tokens + orphan list + base URL). - Commons: ExpectedDeployment projection record (Commons/Types/Deployment/), lightweight join of DeployedConfigSnapshot + Instance (no ConfigJson). - IDeploymentManagerRepository: GetExpectedDeploymentsForSiteAsync (inner-join query returning deployed set for a site, excluding snapshot-less instances) + StagePendingIfAbsentAsync (insert-if-absent, self-contained save, returns bool; does NOT supersede — an existing pending row signals in-flight delivery). - DeploymentManagerRepository: implement both methods; StagePendingIfAbsent commits internally (matches PurgeExpiredPendingDeployments convention). - ReconcileRepositoryTests: 4 tests covering expected-set filter/IsEnabled/ cross-site isolation, empty-site, stage-absent (true + row retrievable), stage-present (false + existing row unchanged); all pass.
This commit is contained in:
+37
@@ -1,5 +1,6 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
@@ -174,6 +175,42 @@ public interface IDeploymentManagerRepository
|
||||
/// <returns>The number of pending deployments purged.</returns>
|
||||
Task<int> PurgeExpiredPendingDeploymentsAsync(DateTimeOffset nowUtc, CancellationToken cancellationToken = default);
|
||||
|
||||
// Startup reconciliation: expected-set query and insert-if-absent staging
|
||||
|
||||
/// <summary>
|
||||
/// Returns the set of instances that central considers deployed for the given site — the join of
|
||||
/// <c>DeployedConfigSnapshot</c> (by InstanceId) with <c>Instance</c> (where SiteId == siteId).
|
||||
/// Instances without a snapshot are excluded. No <c>ConfigurationJson</c> is loaded; the full
|
||||
/// config is fetched on demand via the HTTP endpoint using a freshly-minted token from
|
||||
/// <see cref="StagePendingIfAbsentAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="siteId">The site primary key to filter on.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns>All deployed instances for the site, as lightweight <see cref="ExpectedDeployment"/> projections.</returns>
|
||||
Task<IReadOnlyList<ExpectedDeployment>> GetExpectedDeploymentsForSiteAsync(int siteId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a <see cref="PendingDeployment"/> for the instance ONLY IF no pending row already
|
||||
/// exists for that <c>InstanceId</c>. An existing pending row signals an in-flight deploy that
|
||||
/// is already delivering to the node — do NOT supersede it (contrast with
|
||||
/// <see cref="AddPendingDeploymentAsync"/>, which supersedes). Returns <c>true</c> if the row
|
||||
/// was staged, <c>false</c> if an existing row was found and left untouched.
|
||||
/// This method is self-contained: it commits its own save and returns a meaningful result,
|
||||
/// matching the convention of <see cref="PurgeExpiredPendingDeploymentsAsync"/>.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The instance to stage the pending deployment for.</param>
|
||||
/// <param name="deploymentId">The deployment ID (fetch key).</param>
|
||||
/// <param name="revisionHash">Revision hash of the flattened configuration.</param>
|
||||
/// <param name="configurationJson">JSON-serialized flattened configuration.</param>
|
||||
/// <param name="token">Short-TTL fetch token the site node will present to the HTTP endpoint.</param>
|
||||
/// <param name="createdAtUtc">UTC timestamp for this pending row.</param>
|
||||
/// <param name="expiresAtUtc">UTC expiry after which the token is no longer valid.</param>
|
||||
/// <param name="cancellationToken">A cancellation token that can be used to cancel the operation.</param>
|
||||
/// <returns><c>true</c> if staged; <c>false</c> if a pending row already existed and was left unchanged.</returns>
|
||||
Task<bool> StagePendingIfAbsentAsync(int instanceId, string deploymentId, string revisionHash,
|
||||
string configurationJson, string token, DateTimeOffset createdAtUtc, DateTimeOffset expiresAtUtc,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// Instance lookups for deployment pipeline
|
||||
/// <summary>
|
||||
/// Gets an instance by ID.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Site→central (over ClusterClient) on node startup: the node's local deployed inventory,
|
||||
/// so central can reply with fetch tokens for whatever the node is missing or stale
|
||||
/// (self-heal a node that was down during a deploy).
|
||||
/// </summary>
|
||||
public record ReconcileSiteRequest(
|
||||
string SiteIdentifier,
|
||||
string NodeId,
|
||||
IReadOnlyDictionary<string, string> LocalNameToRevisionHash);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Central→site reply: fresh fetch tokens for the gap (instances the node is missing or has at
|
||||
/// a stale revision), orphan names to log (present locally but no longer deployed centrally),
|
||||
/// and the base URL to fetch from.
|
||||
/// </summary>
|
||||
public record ReconcileSiteResponse(
|
||||
IReadOnlyList<ReconcileGapItem> Gap,
|
||||
IReadOnlyList<string> OrphanNames,
|
||||
string CentralFetchBaseUrl);
|
||||
|
||||
/// <summary>
|
||||
/// One instance the node must (re)fetch, with a freshly-minted short-TTL token.
|
||||
/// </summary>
|
||||
public record ReconcileGapItem(
|
||||
string InstanceUniqueName,
|
||||
string DeploymentId,
|
||||
string RevisionHash,
|
||||
bool IsEnabled,
|
||||
string FetchToken);
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight projection of an instance's last-deployed state, used by the startup-reconciliation
|
||||
/// path to compute the gap between central's expected deployed set and a site node's local inventory.
|
||||
/// No <c>ConfigurationJson</c> is included — the full config is only fetched for gap instances via
|
||||
/// the HTTP endpoint using the freshly-minted <see cref="ReconcileGapItem.FetchToken"/>.
|
||||
/// </summary>
|
||||
/// <param name="InstanceId">The instance primary key.</param>
|
||||
/// <param name="InstanceUniqueName">The system-wide unique name for the instance.</param>
|
||||
/// <param name="RevisionHash">Revision hash of the flattened configuration at last successful deploy.</param>
|
||||
/// <param name="DeploymentId">Deployment ID of the last successful deploy (the fetch key).</param>
|
||||
/// <param name="IsEnabled">True when the instance's current <c>State</c> is <c>Enabled</c>.</param>
|
||||
public record ExpectedDeployment(
|
||||
int InstanceId,
|
||||
string InstanceUniqueName,
|
||||
string RevisionHash,
|
||||
string DeploymentId,
|
||||
bool IsEnabled);
|
||||
+49
@@ -2,6 +2,8 @@ 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;
|
||||
|
||||
@@ -251,6 +253,53 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
return expired.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)
|
||||
{
|
||||
// Insert-if-absent: do NOT supersede an existing pending row. An existing row means an
|
||||
// in-flight deploy is already delivering to the node; clobbering it could cause the node
|
||||
// to fetch the reconcile token while the original deliver is mid-flight.
|
||||
// Self-contained (commits internally), matching PurgeExpiredPendingDeploymentsAsync.
|
||||
var alreadyPending = await _dbContext.Set<PendingDeployment>()
|
||||
.AnyAsync(p => p.InstanceId == instanceId, cancellationToken);
|
||||
if (alreadyPending)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var pending = new PendingDeployment(
|
||||
deploymentId, instanceId, revisionHash,
|
||||
configurationJson, token, createdAtUtc, expiresAtUtc);
|
||||
await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Instance lookups for deployment pipeline ---
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the startup-reconciliation repo methods on <see cref="DeploymentManagerRepository"/>:
|
||||
/// <see cref="IDeploymentManagerRepository.GetExpectedDeploymentsForSiteAsync"/> (central's deployed
|
||||
/// set for a site, excluding instances with no snapshot) and
|
||||
/// <see cref="IDeploymentManagerRepository.StagePendingIfAbsentAsync"/> (insert-if-absent, used by
|
||||
/// the reconcile handler to mint fresh fetch tokens for the gap without clobbering an in-flight deploy).
|
||||
/// Uses the shared SQLite in-memory fixture with real FK enforcement.
|
||||
/// </summary>
|
||||
public class ReconcileRepositoryTests : IDisposable
|
||||
{
|
||||
private readonly ScadaBridgeDbContext _context;
|
||||
private readonly DeploymentManagerRepository _repository;
|
||||
|
||||
public ReconcileRepositoryTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
_repository = new DeploymentManagerRepository(_context);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
// --- Seed helpers ---
|
||||
|
||||
/// <summary>Seeds a Site row and returns its generated id.</summary>
|
||||
private async Task<int> SeedSiteAsync(string name)
|
||||
{
|
||||
var site = new Site(name, $"SI-{name}");
|
||||
_context.Sites.Add(site);
|
||||
await _context.SaveChangesAsync();
|
||||
return site.Id;
|
||||
}
|
||||
|
||||
/// <summary>Seeds a Template + Instance on the given site and returns the instance id.</summary>
|
||||
private async Task<int> SeedInstanceAsync(string uniqueName, int siteId, InstanceState state = InstanceState.Enabled)
|
||||
{
|
||||
var template = new Template($"T-{uniqueName}");
|
||||
_context.Templates.Add(template);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var instance = new Instance(uniqueName) { SiteId = siteId, TemplateId = template.Id, State = state };
|
||||
_context.Instances.Add(instance);
|
||||
await _context.SaveChangesAsync();
|
||||
return instance.Id;
|
||||
}
|
||||
|
||||
/// <summary>Seeds a <see cref="DeployedConfigSnapshot"/> for the given instance and returns it.</summary>
|
||||
private async Task<DeployedConfigSnapshot> SeedSnapshotAsync(int instanceId, string deploymentId, string revisionHash)
|
||||
{
|
||||
var snapshot = new DeployedConfigSnapshot(deploymentId, revisionHash, "{}") { InstanceId = instanceId };
|
||||
_context.Set<DeployedConfigSnapshot>().Add(snapshot);
|
||||
await _context.SaveChangesAsync();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
// --- GetExpectedDeploymentsForSiteAsync ---
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpectedDeployments_ReturnsOnlyDeployedInstancesForSite()
|
||||
{
|
||||
// Arrange: site A has two deployed instances (one Enabled, one Disabled)
|
||||
// site B has one deployed instance
|
||||
// site A also has one instance WITHOUT a snapshot (must be excluded)
|
||||
var siteAId = await SeedSiteAsync("SiteA");
|
||||
var siteBId = await SeedSiteAsync("SiteB");
|
||||
|
||||
var instA1Id = await SeedInstanceAsync("A-Inst1", siteAId, InstanceState.Enabled);
|
||||
var instA2Id = await SeedInstanceAsync("A-Inst2", siteAId, InstanceState.Disabled);
|
||||
var instA3Id = await SeedInstanceAsync("A-Inst3-NoSnap", siteAId, InstanceState.Enabled);
|
||||
var instB1Id = await SeedInstanceAsync("B-Inst1", siteBId, InstanceState.Enabled);
|
||||
|
||||
await SeedSnapshotAsync(instA1Id, "dep-a1", "hash-a1");
|
||||
await SeedSnapshotAsync(instA2Id, "dep-a2", "hash-a2");
|
||||
// instA3 intentionally has no snapshot
|
||||
await SeedSnapshotAsync(instB1Id, "dep-b1", "hash-b1");
|
||||
|
||||
// Act
|
||||
var result = await _repository.GetExpectedDeploymentsForSiteAsync(siteAId);
|
||||
|
||||
// Assert: exactly the two site-A instances that have snapshots
|
||||
Assert.Equal(2, result.Count);
|
||||
|
||||
var byName = result.ToDictionary(r => r.InstanceUniqueName);
|
||||
Assert.True(byName.ContainsKey("A-Inst1"), "A-Inst1 should be present");
|
||||
Assert.True(byName.ContainsKey("A-Inst2"), "A-Inst2 should be present");
|
||||
Assert.False(byName.ContainsKey("A-Inst3-NoSnap"), "instance without snapshot must be excluded");
|
||||
Assert.False(byName.ContainsKey("B-Inst1"), "site B instance must be excluded");
|
||||
|
||||
// Check field correctness for A-Inst1 (Enabled)
|
||||
var a1 = byName["A-Inst1"];
|
||||
Assert.Equal(instA1Id, a1.InstanceId);
|
||||
Assert.Equal("dep-a1", a1.DeploymentId);
|
||||
Assert.Equal("hash-a1", a1.RevisionHash);
|
||||
Assert.True(a1.IsEnabled);
|
||||
|
||||
// Check field correctness for A-Inst2 (Disabled)
|
||||
var a2 = byName["A-Inst2"];
|
||||
Assert.Equal(instA2Id, a2.InstanceId);
|
||||
Assert.Equal("dep-a2", a2.DeploymentId);
|
||||
Assert.Equal("hash-a2", a2.RevisionHash);
|
||||
Assert.False(a2.IsEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetExpectedDeployments_NoDeployedInstances_ReturnsEmpty()
|
||||
{
|
||||
var siteId = await SeedSiteAsync("EmptySite");
|
||||
// Seed an instance but no snapshot
|
||||
await SeedInstanceAsync("Inst-NoSnap", siteId);
|
||||
|
||||
var result = await _repository.GetExpectedDeploymentsForSiteAsync(siteId);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
// --- StagePendingIfAbsentAsync ---
|
||||
|
||||
[Fact]
|
||||
public async Task StagePendingIfAbsent_NoPriorRow_StagesAndReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var siteId = await SeedSiteAsync("SiteX");
|
||||
var instanceId = await SeedInstanceAsync("InstX", siteId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
// Act
|
||||
var staged = await _repository.StagePendingIfAbsentAsync(
|
||||
instanceId,
|
||||
deploymentId: "dep-new",
|
||||
revisionHash: "rev-new",
|
||||
configurationJson: "{\"x\":1}",
|
||||
token: "tok-new",
|
||||
createdAtUtc: now,
|
||||
expiresAtUtc: now.AddMinutes(10));
|
||||
|
||||
// Assert: staged, and the row is retrievable with the correct token + config
|
||||
Assert.True(staged);
|
||||
var row = await _repository.GetPendingDeploymentByIdAsync("dep-new");
|
||||
Assert.NotNull(row);
|
||||
Assert.Equal(instanceId, row!.InstanceId);
|
||||
Assert.Equal("tok-new", row.Token);
|
||||
Assert.Equal("{\"x\":1}", row.ConfigurationJson);
|
||||
Assert.Equal("rev-new", row.RevisionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StagePendingIfAbsent_PriorRowExists_ReturnsFalseAndDoesNotOverwrite()
|
||||
{
|
||||
// Arrange: an existing pending row for the instance (simulates an in-flight deploy)
|
||||
var siteId = await SeedSiteAsync("SiteY");
|
||||
var instanceId = await SeedInstanceAsync("InstY", siteId);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
var existing = new PendingDeployment(
|
||||
"dep-existing", instanceId, revisionHash: "rev-existing",
|
||||
configurationJson: "{\"existing\":true}", token: "tok-existing",
|
||||
createdAtUtc: now.AddMinutes(-5), expiresAtUtc: now.AddMinutes(5));
|
||||
_context.Set<PendingDeployment>().Add(existing);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Act: try to stage a newer row for the same instance
|
||||
var staged = await _repository.StagePendingIfAbsentAsync(
|
||||
instanceId,
|
||||
deploymentId: "dep-reconcile",
|
||||
revisionHash: "rev-reconcile",
|
||||
configurationJson: "{\"reconcile\":true}",
|
||||
token: "tok-reconcile",
|
||||
createdAtUtc: now,
|
||||
expiresAtUtc: now.AddMinutes(10));
|
||||
|
||||
// Assert: skipped (returns false), existing row is unchanged
|
||||
Assert.False(staged);
|
||||
|
||||
var existingRow = await _repository.GetPendingDeploymentByIdAsync("dep-existing");
|
||||
Assert.NotNull(existingRow);
|
||||
Assert.Equal("tok-existing", existingRow!.Token);
|
||||
Assert.Equal("{\"existing\":true}", existingRow.ConfigurationJson);
|
||||
|
||||
// The reconcile row must not exist
|
||||
Assert.Null(await _repository.GetPendingDeploymentByIdAsync("dep-reconcile"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user