c57e74e660
Closes deferred-work-register rows 89 and 90, and corrects their premise twice.
THE RULE
The `data_connection_definitions` store wins wherever it has an opinion; an
instance's flattened `Connections` entry is a deploy-time snapshot and is
authoritative only for names the store has never owned (the deploy-only site,
where that table is legitimately empty).
PREMISE CORRECTION 1 — the peer's DCL is EMPTY, not stale
`DeploymentManagerActor` is a `site-{SiteId}`-scoped singleton and the only
sender of Create/RemoveConnectionCommand, to its OWN node's `dcl-manager`
(which exists on every node but is populated only where the singleton runs).
So the standby serves nothing. The real defect is that `_createdConnections`
is in-memory per singleton incarnation: a restart or failover rebuilds every
connection from the flattened snapshots and silently discards a newer
connection-only artifact deploy. Row 89 is therefore create-time, not
message-delivery, and NOT replication-specific — the same loss happens on an
unreplicated single-node restart.
PREMISE CORRECTION 2 — a changed config never reached a LIVE connection either
`DataConnectionManagerActor.HandleCreateConnection` is create-only (warns and
returns on a name it holds), so `EnsureDclConnection`'s documented "re-issues
the create so the DCL adopts the new configuration" has never been true on any
node. Now surfaced with a Warning saying exactly that, once per changed config.
Not fixed here: stop-and-recreate drops every tag subscription and InstanceActor
cancels its subscribe-retry timer on first success, so nothing re-subscribes.
MECHANISM
`data_connection_definitions` gains its first production reader. It is read on
the SAME off-thread task as the deployed configs (`ReadStoreSnapshotAsync`), by
the boot load and every sweep tick, into an actor-thread-confined arbitration
overlay applied BEFORE the boot batch and BEFORE the sweep's repair loop. A
CDC-delivered definition change converges within one tick on whichever node runs
the singleton — no new cross-node message. A failed definitions read yields null
and leaves the overlay untouched (distinct from an empty list, which is a real
answer); clearing it would reintroduce the ping-pong through the error path.
THE PING-PONG, KILLED BY CONSTRUCTION
§7.4 of the sweep memo rejected a store-level pass because two sources would
flip the single per-name hash slot, recreating the actor every tick. That is a
property of WHERE the hash is computed: every ensure path now resolves the
winner BEFORE hashing, so only one configuration can ever occupy the slot. The
artifact-deploy path writes its rows into the overlay in the same pass, closing
the same flip arriving through the other door.
REMOVAL (row 90)
Both removal paths share `RemoveConnectionCentrally` and record an in-memory
tombstone, so a centrally removed connection is not resurrected by the next
instance start — a pre-existing bug. Tombstones are deliberately not persisted;
a definition row reappearing clears one.
TESTS
tests/.../Actors/DclConnectionArbitrationTests.cs — 8 tests. Fail-before
(behavioral revert: overlay inert + tombstone check disabled): 5 fail, 3 pass;
the 3 passers are the two deploy-only regression guards and the ping-pong guard.
DOCS
New decision memo docs/plans/2026-08-17-dcl-connection-arbitration-decision.md
(topology, rule, ping-pong mechanism, tombstones, residuals); Component-SiteRuntime,
Component-DataConnectionLayer + their docs/components mirrors; the §7.4 residual in
the sweep memo marked closed with its premise corrected.
480 lines
22 KiB
C#
480 lines
22 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
|
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|
using RemoveConnectionCommand = ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors.RemoveConnectionCommand;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
|
|
|
|
/// <summary>
|
|
/// Regression tests for deferred register rows <b>89</b> and <b>90</b> — <b>which configuration
|
|
/// source owns a live DCL connection</b>.
|
|
///
|
|
/// <para>
|
|
/// <b>The arbitration rule under test.</b> The <c>data_connection_definitions</c> store wins
|
|
/// wherever it has an opinion; an instance's flattened <c>Connections</c> entry is a deploy-time
|
|
/// SNAPSHOT and is authoritative only for names the store has never owned (the deploy-only site,
|
|
/// where that table is legitimately empty). Every ensure path resolves the winner BEFORE hashing,
|
|
/// so the single per-name hash slot in <c>_createdConnections</c> can only ever hold one source's
|
|
/// configuration — which is what makes the two-source recreate-on-every-flip ping-pong
|
|
/// unrepresentable rather than merely avoided.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Verified topology (this is what the register rows really describe).</b> The Deployment
|
|
/// Manager is a cluster singleton scoped to <c>site-{SiteId}</c> and is the ONLY sender of
|
|
/// <see cref="CreateConnectionCommand"/> / <see cref="RemoveConnectionCommand"/>; the
|
|
/// <c>dcl-manager</c> actor exists on every site node but only the singleton's node ever
|
|
/// populates one. So the standby's DCL is not "serving the old endpoint" — it is empty, and the
|
|
/// divergence surfaces when the singleton's NEXT incarnation rebuilds every connection from the
|
|
/// flattened snapshots and never consults the newer definition rows. Design record:
|
|
/// <c>docs/plans/2026-08-17-dcl-connection-arbitration-decision.md</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
public class DclConnectionArbitrationTests : TestKit, IDisposable
|
|
{
|
|
private const string ConnectionName = "OPC PLC Simulator";
|
|
|
|
/// <summary>The endpoint frozen into an instance's flattened config at its deploy time.</summary>
|
|
private const string FlattenedEndpoint = "opc.tcp://old-plc:4840";
|
|
|
|
/// <summary>The endpoint a later, connection-only artifact deploy wrote to the store.</summary>
|
|
private const string DefinitionEndpoint = "opc.tcp://new-plc:4840";
|
|
|
|
private readonly SiteStorageService _storage;
|
|
private readonly ScriptCompilationService _compilationService;
|
|
private readonly SharedScriptLibrary _sharedScriptLibrary;
|
|
private readonly TestLocalDb _localDb;
|
|
|
|
private readonly ConcurrentQueue<CreateConnectionCommand> _creates = new();
|
|
private readonly ConcurrentQueue<RemoveConnectionCommand> _removes = new();
|
|
|
|
public DclConnectionArbitrationTests()
|
|
{
|
|
_localDb = TestLocalDb.CreateTemp("dcl-arbitration-test");
|
|
_storage = new SiteStorageService(_localDb.Db, NullLogger<SiteStorageService>.Instance);
|
|
_storage.InitializeAsync().GetAwaiter().GetResult();
|
|
_compilationService = new ScriptCompilationService(
|
|
NullLogger<ScriptCompilationService>.Instance);
|
|
_sharedScriptLibrary = new SharedScriptLibrary(
|
|
_compilationService, NullLogger<SharedScriptLibrary>.Instance);
|
|
}
|
|
|
|
void IDisposable.Dispose()
|
|
{
|
|
// Shut the actors down BEFORE the ILocalDb they write through is disposed.
|
|
Shutdown();
|
|
var path = _localDb.Path;
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
|
|
/// <summary>Shortest sweep the (whole-second) option allows.</summary>
|
|
private const int SweepSeconds = 1;
|
|
|
|
/// <summary>Long enough for at least three sweep ticks to have come and gone.</summary>
|
|
private static readonly TimeSpan SeveralSweeps = TimeSpan.FromSeconds(4);
|
|
|
|
/// <summary>
|
|
/// Starts a Deployment Manager wired to a recording stand-in for the DCL manager, and blocks
|
|
/// until its boot-time store read has PROVABLY completed (see
|
|
/// <see cref="DeploymentManagerStartupProbe"/>). Rows written before that point are consumed
|
|
/// by the boot load, which is a different path from the post-boot sweep — the gate is what
|
|
/// keeps each test proving the path it names.
|
|
/// </summary>
|
|
private IActorRef CreateBootedDeploymentManager(int sweepSeconds = SweepSeconds)
|
|
{
|
|
var dclSpy = ActorOf(Props.Create(() => new DclConnectionSpy(_creates, _removes)));
|
|
var health = new DeploymentManagerStartupProbe();
|
|
|
|
var dm = ActorOf(Props.Create(() => new DeploymentManagerActor(
|
|
_storage,
|
|
_compilationService,
|
|
_sharedScriptLibrary,
|
|
null,
|
|
new SiteRuntimeOptions { InstanceSweepIntervalSeconds = sweepSeconds },
|
|
NullLogger<DeploymentManagerActor>.Instance,
|
|
dclManager: dclSpy,
|
|
healthCollector: health,
|
|
serviceProvider: null,
|
|
loggerFactory: null,
|
|
configFetcher: null)));
|
|
|
|
AwaitCondition(() => health.ReportCount > 0, TimeSpan.FromSeconds(20),
|
|
TimeSpan.FromMilliseconds(25));
|
|
return dm;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A flattened config carrying one data connection. The <c>Connections</c> map is the
|
|
/// deploy-time snapshot the arbitration rule demotes whenever a definition row exists.
|
|
/// </summary>
|
|
private static string MakeConfigJson(string instanceName, string endpoint = FlattenedEndpoint) =>
|
|
JsonSerializer.Serialize(new FlattenedConfiguration
|
|
{
|
|
InstanceUniqueName = instanceName,
|
|
Attributes =
|
|
[
|
|
new ResolvedAttribute
|
|
{
|
|
CanonicalName = "TestAttr", Value = "42", DataType = "Int32"
|
|
}
|
|
],
|
|
Connections = new Dictionary<string, ConnectionConfig>
|
|
{
|
|
[ConnectionName] = new()
|
|
{
|
|
Protocol = "OpcUa",
|
|
ConfigurationJson = EndpointJson(endpoint),
|
|
FailoverRetryCount = 3
|
|
}
|
|
}
|
|
});
|
|
|
|
private static string EndpointJson(string endpoint) => $"{{\"endpointUrl\":\"{endpoint}\"}}";
|
|
|
|
/// <summary>Writes the <c>data_connection_definitions</c> row an artifact deploy would.</summary>
|
|
private Task StoreDefinitionAsync(string endpoint) =>
|
|
_storage.StoreDataConnectionDefinitionAsync(
|
|
ConnectionName, "OpcUa", EndpointJson(endpoint), null, 3);
|
|
|
|
private IReadOnlyList<CreateConnectionCommand> CreatesFor(string connectionName) =>
|
|
_creates.Where(c => c.ConnectionName == connectionName).ToArray();
|
|
|
|
/// <summary>
|
|
/// The endpoint the DCL was actually told to connect to — the whole point of the arbitration.
|
|
/// </summary>
|
|
private static string EndpointOf(CreateConnectionCommand command) =>
|
|
command.PrimaryConnectionDetails["endpoint"];
|
|
|
|
private async Task AwaitCreateCountAsync(int count, string because) =>
|
|
await AwaitAssertAsync(() =>
|
|
{
|
|
Assert.True(CreatesFor(ConnectionName).Count >= count, because);
|
|
return Task.CompletedTask;
|
|
}, TimeSpan.FromSeconds(20));
|
|
|
|
// ── Rule 1: a definition row outranks an older flattened snapshot ──
|
|
|
|
/// <summary>
|
|
/// <b>THE row-89 reproduction, in its real (failover / restart) shape.</b> The store holds a
|
|
/// definition row pointing at the new endpoint — put there by a connection-only artifact
|
|
/// deploy and, on a replicated pair, delivered to this node by LocalDb CDC — while the
|
|
/// instance's own flattened config still carries the endpoint frozen in at ITS deploy time.
|
|
/// A singleton incarnation booting here rebuilds the entire DCL from those flattened
|
|
/// snapshots, so this is exactly what the peer node does the moment it takes over.
|
|
///
|
|
/// <para>
|
|
/// <b>Fails before the fix:</b> the create carries <c>old-plc</c>, because nothing in the
|
|
/// runtime ever read <c>data_connection_definitions</c> — the table had no production reader
|
|
/// at all, so the artifact deploy's change survived only inside the singleton incarnation
|
|
/// that applied it.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DefinitionRow_WinsOverAnOlderFlattenedSnapshot_WhenTheSingletonBoots()
|
|
{
|
|
const string instanceName = "ArbitratedPump";
|
|
|
|
await StoreDefinitionAsync(DefinitionEndpoint);
|
|
await _storage.StoreDeployedConfigAsync(
|
|
instanceName, MakeConfigJson(instanceName), "dep-a", "rev-a", true);
|
|
|
|
CreateBootedDeploymentManager(sweepSeconds: 0);
|
|
|
|
await AwaitCreateCountAsync(1, "the boot load never created the connection");
|
|
|
|
Assert.Equal(DefinitionEndpoint, EndpointOf(CreatesFor(ConnectionName).Single()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>The ping-pong guard.</b> With the two sources disagreeing, the naive form of this fix —
|
|
/// a sweep pass that re-hashes the connection from the definitions table while the deploy
|
|
/// paths re-hash it from the flattened configs — flips the single per-name hash slot on every
|
|
/// tick, and each flip is another <see cref="CreateConnectionCommand"/>, i.e. the live
|
|
/// connection actor recreated and every subscription on it dropped, forever.
|
|
///
|
|
/// <para>
|
|
/// This passes both before and after the fix (before, because nothing read the definitions at
|
|
/// all) and is the reason the ensure paths resolve the WINNER before hashing rather than
|
|
/// guarding the flip after the fact.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DisagreeingSources_DoNotPingPongTheConnection_AcrossManySweeps()
|
|
{
|
|
const string instanceName = "NoPingPongPump";
|
|
|
|
await StoreDefinitionAsync(DefinitionEndpoint);
|
|
await _storage.StoreDeployedConfigAsync(
|
|
instanceName, MakeConfigJson(instanceName), "dep-p", "rev-p", true);
|
|
|
|
CreateBootedDeploymentManager();
|
|
|
|
await AwaitCreateCountAsync(1, "the boot load never created the connection");
|
|
await Task.Delay(SeveralSweeps);
|
|
|
|
Assert.Single(CreatesFor(ConnectionName));
|
|
Assert.Empty(_removes);
|
|
}
|
|
|
|
// ── Rule 2: no definition row means the flattened config is the source, unchanged ──
|
|
|
|
/// <summary>
|
|
/// The deploy-only site: <c>data_connection_definitions</c> is EMPTY because nothing has ever
|
|
/// sent this site a system-wide artifact deploy, and every connection that matters lives
|
|
/// inline in the instance configs. Rule 2 says the flattened config is the source there — not
|
|
/// as a fallback, but as the correct answer — so this must behave exactly as it did before.
|
|
///
|
|
/// <para>Passes before and after: a deploy-only regression guard.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_AtBoot()
|
|
{
|
|
const string instanceName = "DeployOnlyPump";
|
|
|
|
await _storage.StoreDeployedConfigAsync(
|
|
instanceName, MakeConfigJson(instanceName), "dep-d", "rev-d", true);
|
|
|
|
CreateBootedDeploymentManager(sweepSeconds: 0);
|
|
|
|
await AwaitCreateCountAsync(1, "the boot load never created the connection");
|
|
|
|
Assert.Equal(FlattenedEndpoint, EndpointOf(CreatesFor(ConnectionName).Single()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same rule on the POST-boot sweep path (register row 78's start, row 85's connection):
|
|
/// a config that appears in the store on its own starts an instance whose connection, with no
|
|
/// definition row anywhere, still comes from that instance's flattened config.
|
|
///
|
|
/// <para>Passes before and after: a deploy-only regression guard for the sweep path.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task NoDefinitionRow_LeavesTheFlattenedConfigAuthoritative_OnTheSweepPath()
|
|
{
|
|
const string instanceName = "DeployOnlySweptPump";
|
|
CreateBootedDeploymentManager();
|
|
|
|
await _storage.StoreDeployedConfigAsync(
|
|
instanceName, MakeConfigJson(instanceName), "dep-ds", "rev-ds", true);
|
|
|
|
await AwaitCreateCountAsync(1, "the sweep never created the connection");
|
|
|
|
Assert.Equal(FlattenedEndpoint, EndpointOf(CreatesFor(ConnectionName).Single()));
|
|
}
|
|
|
|
// ── Row 89's live half: a definition arriving after boot converges on the next tick ──
|
|
|
|
/// <summary>
|
|
/// <b>A definition row appearing in the store while the node runs</b> — what LocalDb's CDC
|
|
/// applier does on the peer of a replicated pair when a connection-only artifact deploy lands
|
|
/// on the other node. There is no cross-node message for this and none is needed: the sweep
|
|
/// re-reads the table on the next tick, adopts the row into the arbitration overlay, and
|
|
/// pushes the new configuration for the connection the runtime already holds.
|
|
///
|
|
/// <para>
|
|
/// <b>Fails before the fix:</b> exactly one create is ever issued, carrying <c>old-plc</c>,
|
|
/// and no sweep tick ever looks at the definitions table.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DefinitionArrivingAfterBoot_IsAdoptedByTheNextSweepTick()
|
|
{
|
|
const string instanceName = "CdcDeliveredPump";
|
|
CreateBootedDeploymentManager();
|
|
|
|
// The instance starts first, from its own (older) flattened snapshot.
|
|
await _storage.StoreDeployedConfigAsync(
|
|
instanceName, MakeConfigJson(instanceName), "dep-c", "rev-c", true);
|
|
await AwaitCreateCountAsync(1, "the sweep never created the connection");
|
|
Assert.Equal(FlattenedEndpoint, EndpointOf(CreatesFor(ConnectionName).First()));
|
|
|
|
// Now the definition row lands — a bare storage write, the CDC applier's only side effect.
|
|
await StoreDefinitionAsync(DefinitionEndpoint);
|
|
|
|
await AwaitCreateCountAsync(2,
|
|
"a definition row that arrived by replication was never picked up: the live DCL kept "
|
|
+ "the endpoint frozen into an instance's flattened config (register row 89)");
|
|
|
|
Assert.Equal(DefinitionEndpoint, EndpointOf(CreatesFor(ConnectionName).Last()));
|
|
|
|
// And it settles: the overlay now owns the name, so further ticks are silent.
|
|
await Task.Delay(SeveralSweeps);
|
|
Assert.Equal(2, CreatesFor(ConnectionName).Count);
|
|
}
|
|
|
|
// ── Row 90: removal, and not being resurrected by a flattened snapshot ──
|
|
|
|
/// <summary>
|
|
/// <b>THE row-90 reproduction.</b> A definition row this incarnation had adopted disappears
|
|
/// from the store — a central delete, reconciled by an artifact deploy on the other node and
|
|
/// replicated here as a row deletion. The sweep can PROVE that removal (the store owned the
|
|
/// name a tick ago and does not now), so it stops the live connection actor; and the name is
|
|
/// tombstoned, so the next instance start whose flattened config still lists that connection
|
|
/// does not quietly bring it back.
|
|
///
|
|
/// <para>
|
|
/// <b>Fails before the fix</b> at the removal assert: nothing ever noticed, and the orphan
|
|
/// connection actor stayed live until the node restarted.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DefinitionRowDisappearing_StopsTheConnection_AndItIsNotResurrected()
|
|
{
|
|
const string firstInstance = "RemovedConnPump";
|
|
const string secondInstance = "LaterPump";
|
|
|
|
await StoreDefinitionAsync(DefinitionEndpoint);
|
|
await _storage.StoreDeployedConfigAsync(
|
|
firstInstance, MakeConfigJson(firstInstance), "dep-r1", "rev-r1", true);
|
|
|
|
CreateBootedDeploymentManager();
|
|
await AwaitCreateCountAsync(1, "the boot load never created the connection");
|
|
|
|
// Central deleted the connection; the reconcile (or CDC) removes the row.
|
|
await _storage.DeleteDataConnectionDefinitionsExceptAsync([]);
|
|
|
|
await AwaitAssertAsync(() =>
|
|
{
|
|
Assert.True(
|
|
_removes.Any(r => r.ConnectionName == ConnectionName),
|
|
"the definition row vanished and the live DCL connection actor was left running "
|
|
+ "(register row 90)");
|
|
return Task.CompletedTask;
|
|
}, TimeSpan.FromSeconds(20));
|
|
|
|
var createsAfterRemoval = CreatesFor(ConnectionName).Count;
|
|
|
|
// A second instance whose flattened config still names the removed connection is started
|
|
// by the sweep. It must NOT resurrect a connection central has deleted.
|
|
await _storage.StoreDeployedConfigAsync(
|
|
secondInstance, MakeConfigJson(secondInstance), "dep-r2", "rev-r2", true);
|
|
await Task.Delay(SeveralSweeps);
|
|
|
|
Assert.Equal(createsAfterRemoval, CreatesFor(ConnectionName).Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same tombstone, reached through the path that actually removes connections today: an
|
|
/// artifact deploy whose complete data-connection set no longer contains the name, which the
|
|
/// site reconciles by deleting the row and stopping the actor.
|
|
///
|
|
/// <para>
|
|
/// <b>Fails before the fix</b> at the final assert: the removal evicted the hash slot but
|
|
/// left no memory of WHY, so the very next instance start re-created the connection from that
|
|
/// instance's flattened config — a centrally-deleted connection resurrected within one sweep
|
|
/// tick.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ArtifactReconcileRemoval_IsNotResurrectedByALaterInstanceStart()
|
|
{
|
|
const string instanceName = "PostReconcilePump";
|
|
var dm = CreateBootedDeploymentManager();
|
|
|
|
// Artifact deploy 1 — the connection exists centrally.
|
|
dm.Tell(new DeployArtifactsCommand(
|
|
"artifact-1", null, null, null, null,
|
|
[new DataConnectionArtifact(ConnectionName, "OpcUa", EndpointJson(DefinitionEndpoint), null)],
|
|
null, DateTimeOffset.UtcNow));
|
|
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(20)).Success);
|
|
|
|
// Artifact deploy 2 — central no longer has it, so the site reconciles it away.
|
|
dm.Tell(new DeployArtifactsCommand(
|
|
"artifact-2", null, null, null, null, [], null, DateTimeOffset.UtcNow));
|
|
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(20)).Success);
|
|
|
|
await AwaitAssertAsync(() =>
|
|
{
|
|
Assert.True(
|
|
_removes.Any(r => r.ConnectionName == ConnectionName),
|
|
"the artifact reconcile did not stop the removed connection");
|
|
return Task.CompletedTask;
|
|
}, TimeSpan.FromSeconds(20));
|
|
|
|
var createsAfterRemoval = CreatesFor(ConnectionName).Count;
|
|
|
|
// An instance start naming the removed connection must not undo the removal.
|
|
await _storage.StoreDeployedConfigAsync(
|
|
instanceName, MakeConfigJson(instanceName), "dep-pr", "rev-pr", true);
|
|
await Task.Delay(SeveralSweeps);
|
|
|
|
Assert.Equal(createsAfterRemoval, CreatesFor(ConnectionName).Count);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The tombstone must not be permanent: a definition row IS the store re-asserting ownership
|
|
/// of the name, so a connection central deletes and then re-adds is creatable again.
|
|
///
|
|
/// <para>
|
|
/// <b>Note what the re-add does and does not do.</b> The overlay is an arbiter, not a creation
|
|
/// source — clearing the tombstone does not by itself resurrect a connection actor, exactly as
|
|
/// a definition row for a name no instance references creates nothing. The connection comes
|
|
/// back with the next instance start, and it comes back at the RE-ADDED endpoint, which is
|
|
/// what proves both halves: the tombstone was cleared, and rule 1 still governs the winner.
|
|
/// This mirrors the removal's own long-standing semantic — a removed connection's attributes
|
|
/// stay bad quality until the referencing instance is redeployed.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ReAddedDefinition_ClearsTheTombstone_SoTheNextInstanceStartRecreatesIt()
|
|
{
|
|
const string firstInstance = "ReAddedPump";
|
|
const string laterInstance = "ReAddedLaterPump";
|
|
|
|
await StoreDefinitionAsync(DefinitionEndpoint);
|
|
await _storage.StoreDeployedConfigAsync(
|
|
firstInstance, MakeConfigJson(firstInstance), "dep-ra", "rev-ra", true);
|
|
|
|
CreateBootedDeploymentManager();
|
|
await AwaitCreateCountAsync(1, "the boot load never created the connection");
|
|
|
|
await _storage.DeleteDataConnectionDefinitionsExceptAsync([]);
|
|
await AwaitAssertAsync(() =>
|
|
{
|
|
Assert.True(_removes.Any(r => r.ConnectionName == ConnectionName),
|
|
"the removal never happened");
|
|
return Task.CompletedTask;
|
|
}, TimeSpan.FromSeconds(20));
|
|
|
|
// Central re-adds it, at a third endpoint.
|
|
const string reAddedEndpoint = "opc.tcp://re-added-plc:4840";
|
|
await StoreDefinitionAsync(reAddedEndpoint);
|
|
|
|
// A later instance start is what actually wants a connection actor again.
|
|
await _storage.StoreDeployedConfigAsync(
|
|
laterInstance, MakeConfigJson(laterInstance), "dep-ral", "rev-ral", true);
|
|
|
|
await AwaitCreateCountAsync(2,
|
|
"the tombstone outlived the re-add, so a connection central had restored stayed dead");
|
|
Assert.Equal(reAddedEndpoint, EndpointOf(CreatesFor(ConnectionName).Last()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stand-in for the DCL manager actor: records the two commands the Deployment Manager is the
|
|
/// sole sender of, and swallows everything else (Instance Actors send their tag subscribes
|
|
/// here too, and those are not what these tests assert on).
|
|
/// </summary>
|
|
private sealed class DclConnectionSpy : ReceiveActor
|
|
{
|
|
public DclConnectionSpy(
|
|
ConcurrentQueue<CreateConnectionCommand> creates,
|
|
ConcurrentQueue<RemoveConnectionCommand> removes)
|
|
{
|
|
Receive<CreateConnectionCommand>(creates.Enqueue);
|
|
Receive<RemoveConnectionCommand>(removes.Enqueue);
|
|
ReceiveAny(_ => { });
|
|
}
|
|
}
|
|
}
|