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:
+91
-12
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user