fix(deployment): detach each instance's settled rows from the bulk deploy's shared unit of work after Finalize (review 2026-10-21 04/M3)
This commit is contained in:
@@ -167,7 +167,11 @@ one is parallel:
|
||||
does not reintroduce serialization. Shape mirrors
|
||||
`ArtifactDeploymentService.DeployCoreAsync`.
|
||||
3. **Finalize (serial).** Commit terminal statuses, apply post-success side
|
||||
effects, write audit rows, release each operation lock.
|
||||
effects, write audit rows, release each operation lock. Each finalised
|
||||
instance's settled rows (staged `PendingDeployment`, snapshot, deployment
|
||||
record) are then detached from the batch's shared unit of work, so the batch
|
||||
does not hold N configs' JSON in the change tracker — or re-scan them on every
|
||||
later save — until it ends (review 2026-10-21 04/M3).
|
||||
|
||||
**Disabled instances are skipped.** A bulk site deploy never touches an instance
|
||||
in `Disabled` — no flatten, no send, no state change — because deploy is a legal
|
||||
|
||||
+13
@@ -466,4 +466,17 @@ public interface IDeploymentManagerRepository
|
||||
/// </summary>
|
||||
/// <returns>The number of tracked entries detached (0 when nothing was pending).</returns>
|
||||
int DetachPendingChanges();
|
||||
|
||||
/// <summary>
|
||||
/// Stops tracking the SETTLED deployment rows of one instance — its
|
||||
/// <see cref="PendingDeployment"/>, <see cref="DeployedConfigSnapshot"/> and
|
||||
/// <see cref="DeploymentRecord"/> entries in the <c>Unchanged</c> state. A bulk site deploy
|
||||
/// shares one scoped unit of work across every instance; without this, each finalised
|
||||
/// instance's config JSON (staged copy, old and new snapshot) stays tracked for the rest of
|
||||
/// the batch and every later <c>SaveChanges</c> re-scans it. Entries with pending writes are
|
||||
/// left alone.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The instance whose settled rows to stop tracking.</param>
|
||||
/// <returns>The number of entries detached.</returns>
|
||||
int DetachSettledDeploymentRows(int instanceId);
|
||||
}
|
||||
|
||||
+21
@@ -844,6 +844,27 @@ public class DeploymentManagerRepository : IDeploymentManagerRepository
|
||||
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
|
||||
|
||||
@@ -1051,6 +1051,13 @@ public class DeploymentService
|
||||
{
|
||||
step.LockHandle?.Dispose();
|
||||
}
|
||||
|
||||
// This instance is done. The batch shares ONE scoped unit of work, so
|
||||
// without this its staged copy and old/new snapshot JSON would stay
|
||||
// tracked (and re-scanned by every later SaveChanges) until the whole
|
||||
// batch ends; dropping the step's own copy of the config lets it go too.
|
||||
_repository.DetachSettledDeploymentRows(step.Instance.Id);
|
||||
prepared[finalizedUpTo] = step with { ConfigJson = string.Empty };
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
+34
@@ -3,6 +3,7 @@ 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.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
|
||||
@@ -416,4 +417,37 @@ public class PendingDeploymentRepositoryTests : IDisposable
|
||||
var row = await _repository.GetPendingDeploymentByIdAsync("dep-x");
|
||||
Assert.Equal("{\"fresh\":true}", row!.ConfigurationJson);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// review 2026-10-21 04/M3: detaching an instance's settled rows stops tracking its saved
|
||||
/// pending row, snapshot and deployment record, and leaves another instance's rows and any
|
||||
/// entry with a pending write alone.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DetachSettledDeploymentRows_DetachesOnlyThatInstancesUnchangedRows()
|
||||
{
|
||||
var mine = await SeedInstanceAsync("InstM3a");
|
||||
var other = await SeedInstanceAsync("InstM3b");
|
||||
await _repository.AddPendingDeploymentAsync(NewPending("m3-mine", mine, "{}"));
|
||||
await _repository.AddPendingDeploymentAsync(NewPending("m3-other", other, "{}"));
|
||||
await _repository.AddDeployedSnapshotAsync(new DeployedConfigSnapshot("m3-mine", "rev", "{}") { InstanceId = mine });
|
||||
var record = new DeploymentRecord("m3-mine", "admin") { InstanceId = mine, Status = DeploymentStatus.Success };
|
||||
await _repository.AddDeploymentRecordAsync(record);
|
||||
await _repository.SaveChangesAsync();
|
||||
|
||||
var detached = _repository.DetachSettledDeploymentRows(mine);
|
||||
|
||||
Assert.Equal(3, detached);
|
||||
Assert.DoesNotContain(_context.ChangeTracker.Entries<PendingDeployment>(), e => e.Entity.InstanceId == mine);
|
||||
Assert.Empty(_context.ChangeTracker.Entries<DeployedConfigSnapshot>());
|
||||
Assert.Empty(_context.ChangeTracker.Entries<DeploymentRecord>());
|
||||
Assert.Contains(_context.ChangeTracker.Entries<PendingDeployment>(), e => e.Entity.InstanceId == other);
|
||||
|
||||
// A row with a pending write is never detached.
|
||||
await _repository.AddDeploymentRecordAsync(
|
||||
new DeploymentRecord("m3-unsaved", "admin") { InstanceId = mine, Status = DeploymentStatus.InProgress });
|
||||
Assert.Equal(0, _repository.DetachSettledDeploymentRows(mine));
|
||||
Assert.Single(_context.ChangeTracker.Entries<DeploymentRecord>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,6 +592,26 @@ public class DeploySiteAsyncTests : TestKit
|
||||
Assert.All(sessions, s => Assert.Same(sessions[0], s));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// review 2026-10-21 04/M3: once an instance is finalised its settled deployment rows are
|
||||
/// detached from the batch's shared unit of work, so N instances' config JSON is not held
|
||||
/// (and re-scanned by every later save) for the rest of the batch.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DeploySiteAsync_DetachesEachInstancesSettledRowsAfterFinalize()
|
||||
{
|
||||
ArrangeInstances(3);
|
||||
var applied = new ConcurrentBag<string>();
|
||||
var commActor = Sys.ActorOf(Props.Create(() => new RecordingSiteActor(applied)));
|
||||
|
||||
var result = await CreateService(commActor, maxParallelism: 3).DeploySiteAsync(SiteId, "admin");
|
||||
|
||||
Assert.Equal(3, result.Value.SuccessCount);
|
||||
foreach (var id in new[] { 1, 2, 3 })
|
||||
_repo.Received(1).DetachSettledDeploymentRows(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>force</c> restores the unconditional behaviour for operators whose
|
||||
/// site state central cannot see — a restored data volume or a rebuilt node
|
||||
|
||||
Reference in New Issue
Block a user