Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/DeploymentRetentionTests.cs
T
Joseph Doherty f1c52385ed fix(controlplane): retention keep window counts Sealed rows; the newest Sealed blob is never pruned
The window used to count every terminal row, so 50+ failed deploys pushed the last good artifact out of it.
Now the newest KeepSealed (>= 1) Sealed rows keep their blob, failed rows are prunable unless referenced in
the ReferencedDays window. Pinned: 60 TimedOut + 1 old Sealed -> Sealed retained, 60 TimedOut emptied.

Refs #733
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:39:34 -04:00

234 lines
11 KiB
C#

using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Retention;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:1.2 — every deploy stored the whole fleet snapshot forever. The retention sweep empties the
/// blob on old terminal rows (never deletes a row, never touches an in-flight one, never the newest N,
/// never one a node applied recently).
/// </summary>
public sealed class DeploymentRetentionTests : ControlPlaneActorTestBase
{
private static readonly DateTime SweepNow = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Sweep_empties_blobs_beyond_the_keep_window_and_leaves_the_newest_intact()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 12, mixInTimedOut: false); // ids[0] newest … ids[11] oldest
var options = new DeploymentRetentionOptions { KeepSealed = 5, ReferencedDays = 30, MaxRowsPerSweep = 200 };
int pruned;
await using (var db = f.CreateDbContext())
pruned = await DeploymentRetentionActor.SweepAsync(db, options, SweepNow);
pruned.ShouldBe(7);
await using var verify = f.CreateDbContext();
foreach (var id in ids.Take(5))
(await verify.Deployments.SingleAsync(d => d.DeploymentId == id)).ArtifactBlob.Length.ShouldBeGreaterThan(0, "newest 5 keep their blob");
foreach (var id in ids.Skip(5))
(await verify.Deployments.SingleAsync(d => d.DeploymentId == id)).ArtifactBlob.ShouldBeEmpty("older rows are emptied");
// Rows survive: the audit record is intact.
(await verify.Deployments.CountAsync()).ShouldBe(12);
// Idempotent: a second sweep finds nothing.
await using var again = f.CreateDbContext();
(await DeploymentRetentionActor.SweepAsync(again, options, SweepNow)).ShouldBe(0);
}
[Fact]
public async Task Sweep_keeps_a_row_a_node_applied_recently_and_never_touches_an_in_flight_row()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 4);
var inFlight = Guid.NewGuid();
await using (var db = f.CreateDbContext())
{
db.ClusterNodes.Add(new ClusterNode
{
NodeId = "site-a-1:4053", ClusterId = "SITE-A", Host = "site-a-1",
ApplicationUri = "urn:x", Enabled = true, CreatedBy = "test",
});
// ids[3] is the oldest; a node applied it yesterday, so it stays.
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = "site-a-1:4053", DeploymentId = ids[3], Status = NodeDeploymentStatus.Applied,
StartedAtUtc = SweepNow.AddDays(-1), AppliedAtUtc = SweepNow.AddDays(-1),
});
// ids[2]: applied long ago — not protected.
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = "site-a-1:4053", DeploymentId = ids[2], Status = NodeDeploymentStatus.Applied,
StartedAtUtc = SweepNow.AddDays(-90), AppliedAtUtc = SweepNow.AddDays(-90),
});
// An ancient in-flight row with a blob — never touched.
db.Deployments.Add(new Deployment
{
DeploymentId = inFlight, RevisionHash = new string('f', 64), Status = DeploymentStatus.AwaitingApplyAcks,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddDays(-400), ArtifactBlob = new byte[] { 1, 2, 3 },
});
await db.SaveChangesAsync();
}
var options = new DeploymentRetentionOptions { KeepSealed = 1, ReferencedDays = 30 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(2); // ids[1], ids[2]
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[0])).ArtifactBlob.ShouldNotBeEmpty("newest");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[1])).ArtifactBlob.ShouldBeEmpty();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[2])).ArtifactBlob.ShouldBeEmpty("applied 90 days ago");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[3])).ArtifactBlob.ShouldNotBeEmpty("applied yesterday");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == inFlight)).ArtifactBlob.ShouldNotBeEmpty("in flight");
}
/// <summary>
/// Fix round 1 / I3 — the keep window counts SEALED rows, not "terminal" ones. A burst of 60 failed
/// deploys used to fill a 50-row terminal window on its own and push the fleet's last good (Sealed)
/// artifact out of it — the one every boot / drift / fallback path reads. The newest Sealed blob is
/// never pruned; the unreferenced TimedOut rows are (positive control: the sweep did run).
/// </summary>
[Fact]
public async Task Sixty_timed_out_deploys_never_prune_the_newest_sealed_blob()
{
var f = NewInMemoryDbFactory();
var sealedId = Guid.NewGuid();
var timedOut = new List<Guid>();
await using (var db = f.CreateDbContext())
{
db.Deployments.Add(new Deployment
{
DeploymentId = sealedId, RevisionHash = new string('s', 64), Status = DeploymentStatus.Sealed,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddDays(-200), SealedAtUtc = SweepNow.AddDays(-200),
ArtifactBlob = new byte[] { 1, 2, 3 },
});
for (var i = 0; i < 60; i++)
{
var id = Guid.NewGuid();
timedOut.Add(id);
db.Deployments.Add(new Deployment
{
DeploymentId = id, RevisionHash = new string('t', 64), Status = DeploymentStatus.TimedOut,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddHours(-i), ArtifactBlob = new byte[] { 9 },
});
}
await db.SaveChangesAsync();
}
var options = new DeploymentRetentionOptions { KeepSealed = 50, ReferencedDays = 30, MaxRowsPerSweep = 200 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(60);
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == sealedId)).ArtifactBlob
.ShouldNotBeEmpty("the newest Sealed artifact is never pruned");
(await verify.Deployments.CountAsync(d => timedOut.Contains(d.DeploymentId) && d.ArtifactBlob.Length == 0))
.ShouldBe(60);
}
/// <summary>Even with KeepSealed misconfigured to 0, the newest Sealed row keeps its blob.</summary>
[Fact]
public async Task The_newest_sealed_blob_survives_a_zero_keep_window()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 3, mixInTimedOut: false);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, new DeploymentRetentionOptions { KeepSealed = 0 }, SweepNow))
.ShouldBe(2);
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[0])).ArtifactBlob.ShouldNotBeEmpty();
}
[Fact]
public async Task Sweep_is_bounded_per_call_and_drains_a_backlog_over_calls()
{
var f = NewInMemoryDbFactory();
Seed(f, sealedCount: 10);
var options = new DeploymentRetentionOptions { KeepSealed = 2, MaxRowsPerSweep = 3 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(3);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(3);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(2);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(0);
}
/// <summary>The actor runs the same rule off its thread and reports back.</summary>
[Fact]
public void Actor_sweeps_on_request_and_reports_the_count()
{
var f = NewInMemoryDbFactory();
Seed(f, sealedCount: 6);
var actor = Sys.ActorOf(DeploymentRetentionActor.Props(f, new DeploymentRetentionOptions
{
KeepSealed = 4, InitialDelaySeconds = 3600, IntervalMinutes = 60,
}));
actor.Tell(DeploymentRetentionActor.Sweep.Instance);
ExpectMsg<DeploymentRetentionActor.SweepCompleted>(PresenceBudget).Pruned.ShouldBe(2);
using var verify = f.CreateDbContext();
verify.Deployments.Count(d => d.ArtifactBlob.Length == 0).ShouldBe(2);
}
[Fact]
public void Options_bind_from_configuration_with_defaults()
{
var defaults = DeploymentRetentionOptions.Bind(null);
defaults.Enabled.ShouldBeTrue();
defaults.KeepSealed.ShouldBe(50);
defaults.ReferencedDays.ShouldBe(30);
defaults.IntervalMinutes.ShouldBe(60);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Deployments:Retention:KeepSealed"] = "7",
["Deployments:Retention:Enabled"] = "false",
})
.Build();
var bound = DeploymentRetentionOptions.Bind(config);
bound.KeepSealed.ShouldBe(7);
bound.Enabled.ShouldBeFalse();
bound.ReferencedDays.ShouldBe(30);
}
/// <summary>Seeds sealed deployments, newest first; each one hour older than the previous.</summary>
private static List<Guid> Seed(IDbContextFactory<OtOpcUaConfigDbContext> f, int sealedCount, bool mixInTimedOut = true)
{
var ids = new List<Guid>();
using var db = f.CreateDbContext();
for (var i = 0; i < sealedCount; i++)
{
var id = Guid.NewGuid();
ids.Add(id);
db.Deployments.Add(new Deployment
{
DeploymentId = id,
RevisionHash = new string('a', 64),
Status = mixInTimedOut && i % 3 == 2 ? DeploymentStatus.TimedOut : DeploymentStatus.Sealed,
CreatedBy = "test",
CreatedAtUtc = SweepNow.AddHours(-i),
SealedAtUtc = SweepNow.AddHours(-i),
ArtifactBlob = new byte[] { 0x1f, 0x8b, (byte)i },
});
}
db.SaveChanges();
return ids;
}
}