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
@@ -32,6 +32,15 @@ public class DataConnectionManagerActor : ReceiveActor
private readonly OpcUaGlobalOptions _opcUaGlobalOptions;
private readonly Dictionary<string, IActorRef> _connectionActors = new();
// Children this manager has STOPPED (RemoveConnectionCommand) but whose Terminated has not
// arrived yet, keyed by actor name. Akka keeps a stopping child's name reserved until it has
// fully terminated, so a create of the same name inside that dispose window must wait for
// Terminated rather than be refused as a collision (review 2026-10-21 follow-up L2).
private readonly Dictionary<string, IActorRef> _stoppingChildren = new();
// Creates queued behind a stopping child of the same actor name; the latest command wins.
private readonly Dictionary<string, CreateConnectionCommand> _queuedCreates = new();
/// <summary>
/// Initializes a new <see cref="DataConnectionManagerActor"/> with the required dependencies.
/// </summary>
@@ -71,6 +80,7 @@ public class DataConnectionManagerActor : ReceiveActor
Receive<SearchAddressSpaceCommand>(HandleSearch);
Receive<ReadTagValuesCommand>(HandleReadTagValues);
Receive<VerifyEndpointCommand>(HandleVerifyEndpoint);
Receive<Terminated>(HandleChildTerminated);
}
private void HandleCreateConnection(CreateConnectionCommand command)
@@ -81,6 +91,28 @@ public class DataConnectionManagerActor : ReceiveActor
return;
}
var actorName = ConnectionActorNames.Sanitize(command.ConnectionName);
if (_stoppingChildren.ContainsKey(actorName))
{
// The previous actor of this name is still terminating (a Remove→Create redeploy):
// its name is reserved until Terminated, so queue the create rather than refuse it —
// a refusal is permanent, because the deployment manager has already recorded the
// config hash and will not resend (review 2026-10-21 follow-up L2).
if (_queuedCreates.TryGetValue(actorName, out var queued)
&& !string.Equals(queued.ConnectionName, command.ConnectionName, StringComparison.Ordinal))
{
RefuseCollision(command, actorName,
$"actor name [{actorName}] is already claimed by queued connection '{queued.ConnectionName}'");
return;
}
_queuedCreates[actorName] = command;
_log.Info(
"Connection {0} queued: the previous actor '{1}' is still stopping; it is created on Terminated",
command.ConnectionName, actorName);
return;
}
// Factory creates the correct adapter based on protocol type
var adapter = _factory.Create(command.ProtocolType, command.PrimaryConnectionDetails);
@@ -97,10 +129,11 @@ public class DataConnectionManagerActor : ReceiveActor
// ("PLC A" / "PLC-A"), and ActorOf throws synchronously on that (or on any name Akka
// refuses): that must fail THIS connection only, never escape to the supervisor and
// restart the whole manager with every live connection (review 2026-10-21, lane A M2).
var actorName = ConnectionActorNames.Sanitize(command.ConnectionName);
IActorRef actorRef;
try
{
// Only a LIVE child of this name is a collision; a child this manager stopped was
// handled above by queueing.
if (!Context.Child(actorName).IsNobody())
throw new InvalidActorNameException(
$"actor name [{actorName}] is already used by another connection");
@@ -108,17 +141,7 @@ public class DataConnectionManagerActor : ReceiveActor
}
catch (InvalidActorNameException ex)
{
_log.Error(
"Connection {0} not created: its actor name '{1}' is invalid or already used by another " +
"connection ({2}). Rename the connection so its name differs in more than spaces/symbols.",
command.ConnectionName, actorName, ex.Message);
if (_siteEventLogger != null)
{
_ = _siteEventLogger.LogEventAsync(
"connection", "Error", null, command.ConnectionName,
$"Connection not created — runtime actor name '{actorName}' is invalid or already in use",
ex.Message);
}
RefuseCollision(command, actorName, ex.Message);
adapter.DisposeAsync().AsTask().ContinueWith(_ => { });
return;
}
@@ -362,10 +385,66 @@ public class DataConnectionManagerActor : ReceiveActor
.PipeTo(Sender);
}
private void RefuseCollision(CreateConnectionCommand command, string actorName, string reason)
{
_log.Error(
"Connection {0} not created: its actor name '{1}' is invalid or already used by another " +
"connection ({2}). Rename the connection so its name differs in more than spaces/symbols.",
command.ConnectionName, actorName, reason);
if (_siteEventLogger != null)
{
_ = _siteEventLogger.LogEventAsync(
"connection", "Error", null, command.ConnectionName,
$"Connection not created — runtime actor name '{actorName}' is invalid or already in use",
reason);
}
}
/// <summary>
/// A child this manager stopped has fully terminated, releasing its actor name: run any
/// create that was queued behind it (review 2026-10-21 follow-up L2).
/// </summary>
private void HandleChildTerminated(Terminated terminated)
{
string? actorName = null;
foreach (var (name, child) in _stoppingChildren)
{
if (child.Equals(terminated.ActorRef))
{
actorName = name;
break;
}
}
if (actorName is null)
return;
_stoppingChildren.Remove(actorName);
if (_queuedCreates.Remove(actorName, out var queued))
{
_log.Info("Previous actor '{0}' terminated — creating queued connection {1}",
actorName, queued.ConnectionName);
HandleCreateConnection(queued);
}
}
private void HandleRemoveConnection(RemoveConnectionCommand command)
{
// A Remove for a connection whose create is still queued behind a stopping child
// cancels that create — the connection is meant to be gone.
var removedActorName = ConnectionActorNames.Sanitize(command.ConnectionName);
if (_queuedCreates.TryGetValue(removedActorName, out var queued)
&& string.Equals(queued.ConnectionName, command.ConnectionName, StringComparison.Ordinal))
{
_queuedCreates.Remove(removedActorName);
_log.Info("Queued create for connection {0} cancelled by remove", command.ConnectionName);
}
if (_connectionActors.TryGetValue(command.ConnectionName, out var actor))
{
// Watch before stopping so the Terminated that frees the actor name reaches us.
Context.Watch(actor);
_stoppingChildren[actor.Path.Name] = actor;
Context.Stop(actor);
_connectionActors.Remove(command.ConnectionName);
_healthCollector.RemoveConnection(command.ConnectionName);