fix(dcl): queue a create whose actor name belongs to a still-terminating child instead of refusing it (review 2026-10-21 follow-up L2)

This commit is contained in:
Joseph Doherty
2026-10-22 09:46:52 -04:00
parent 530a096111
commit e60c316f30
3 changed files with 141 additions and 13 deletions
@@ -20,6 +20,7 @@ public class ConnectionActorNameTests : TestKit
{
private readonly IDataConnectionFactory _factory = Substitute.For<IDataConnectionFactory>();
private readonly ISiteHealthCollector _health = Substitute.For<ISiteHealthCollector>();
private volatile bool _slowDispose;
private readonly DataConnectionOptions _options = new()
{
ReconnectInterval = TimeSpan.FromMilliseconds(100),
@@ -37,6 +38,13 @@ public class ConnectionActorNameTests : TestKit
adapter.Status.Returns(ConnectionHealth.Connected);
adapter.SubscribeAsync(Arg.Any<string>(), Arg.Any<SubscriptionCallback>(), Arg.Any<CancellationToken>())
.Returns("sub-1");
// A synchronous delay inside DisposeAsync holds the stopping child in PostStop, which
// keeps its actor name reserved — the dispose window the L2 tests exercise.
adapter.DisposeAsync().Returns(_ =>
{
if (_slowDispose) Thread.Sleep(400);
return ValueTask.CompletedTask;
});
return adapter;
});
}
@@ -89,4 +97,45 @@ public class ConnectionActorNameTests : TestKit
Assert.Contains("Unknown connection", Subscribe(manager, "PLC-A").ErrorMessage);
Assert.DoesNotContain("Unknown connection", Subscribe(manager, "PLC A").ErrorMessage ?? "");
}
/// <summary>
/// Follow-up L2: a Remove immediately followed by a Create of the SAME name (a redeploy that
/// rebuilds the connection) lands while the stopped child is still terminating, so its actor
/// name is still reserved. The create used to be refused permanently as a "collision" — and
/// the deployment manager, having recorded the config hash, never resent it. The manager now
/// queues the create until the old child's Terminated arrives.
/// </summary>
[Fact]
public void RemoveThenCreateSameName_InsideDisposeWindow_IsCreatedOnceTheOldChildTerminates()
{
_slowDispose = true;
var manager = CreateManager();
manager.Tell(new CreateConnectionCommand("PLC1", "OpcUa", new Dictionary<string, string>(), null, 3));
manager.Tell(new RemoveConnectionCommand("PLC1"));
manager.Tell(new CreateConnectionCommand("PLC1", "OpcUa", new Dictionary<string, string>(), null, 3));
AwaitAssert(
() => Assert.DoesNotContain("Unknown connection", Subscribe(manager, "PLC1").ErrorMessage ?? ""),
TimeSpan.FromSeconds(10),
TimeSpan.FromMilliseconds(100));
}
/// <summary>
/// Follow-up L2: a Remove that arrives while a create is still queued behind a terminating
/// child cancels the queued create — the connection stays removed.
/// </summary>
[Fact]
public void RemoveWhileCreateQueued_CancelsTheQueuedCreate()
{
_slowDispose = true;
var manager = CreateManager();
manager.Tell(new CreateConnectionCommand("PLC2", "OpcUa", new Dictionary<string, string>(), null, 3));
manager.Tell(new RemoveConnectionCommand("PLC2"));
manager.Tell(new CreateConnectionCommand("PLC2", "OpcUa", new Dictionary<string, string>(), null, 3));
manager.Tell(new RemoveConnectionCommand("PLC2"));
// Let the old child terminate (and any wrongly-queued create run) before asserting.
Thread.Sleep(1000);
Assert.Contains("Unknown connection", Subscribe(manager, "PLC2").ErrorMessage);
}
}