fix(site-runtime): reconcile artifact deletions on apply — central deletes no longer orphan site rows

The artifact apply (DeploymentManagerActor.HandleDeployArtifacts) was
upsert-only, so deleting an external system (or shared script, DB connection,
data connection) centrally never removed the site's SQLite row — a deleted
external system stayed callable from site scripts forever. Central always
ships the COMPLETE set of each artifact class (ArtifactDeploymentService
GetAll* snapshots; the wire's presence-tracking wrapper lists preserve
null-vs-empty), so the site now applies upsert-then-reconcile: after storing
the incoming set, SiteStorageService.DeleteRowsExceptAsync removes any stored
row absent from it, per artifact table. A null list still means 'field not
shipped' and touches nothing.

Runtime cleanup rides along: a reconciled-away shared script is unregistered
from the compiled SharedScriptLibrary (a stale delegate would stay callable
until restart), and a removed data connection is evicted from the DCL hash
cache and its live connection actor stopped via the previously-caller-less
RemoveConnectionCommand — both on the actor thread via the extended
ApplyArtifactDataConnectionsToDcl message. All four tables are
RegisterReplicated, so the deletes reach the standby as ordinary CDC row
tombstones.

Tests: storage-level reconcile per table (incl. empty-set-deletes-all and
idempotency) in ArtifactStorageTests; actor-level pins in
DeploymentManagerActorTests (orphan delete, null-set no-op, library
unregistration, DCL stop for the removed connection only). Docs:
Component-DeploymentManager + Component-SiteRuntime record the
full-set/reconcile semantics.
This commit is contained in:
Joseph Doherty
2026-08-01 10:54:18 -04:00
parent 0123b68719
commit 2d03f2d507
6 changed files with 342 additions and 6 deletions
@@ -57,7 +57,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
private IActorRef CreateDeploymentManager(
SiteRuntimeOptions? options = null, IServiceProvider? serviceProvider = null,
IDeploymentConfigFetcher? configFetcher = null)
IDeploymentConfigFetcher? configFetcher = null, IActorRef? dclManager = null)
{
options ??= new SiteRuntimeOptions();
return ActorOf(Props.Create(() => new DeploymentManagerActor(
@@ -70,7 +70,7 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
// Named from here on. These trailing parameters are all optional and several
// share a type, so a positional list silently binds the wrong argument when the
// signature changes — which is exactly what removing replicationActor did.
dclManager: null,
dclManager: dclManager,
healthCollector: null,
serviceProvider: serviceProvider,
loggerFactory: null,
@@ -971,6 +971,127 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
"The plaintext SMTP password is still on disk.");
}
// ── Artifact set reconciliation (orphan cleanup) ──
//
// Central ships the COMPLETE system-wide set of each artifact class on every
// artifact deployment, so the apply must delete stored rows absent from the
// incoming set — they were deleted centrally. The apply used to be upsert-only,
// leaving e.g. a centrally-deleted external system callable from site scripts
// forever. ArtifactStorageTests covers the storage deletes in isolation; these
// tests pin the ACTOR's calls to them (and the runtime cleanup that must ride
// along: shared-script library unregistration, DCL connection stop).
private static DeployArtifactsCommand ArtifactsCommand(
string deploymentId,
IReadOnlyList<SharedScriptArtifact>? sharedScripts = null,
IReadOnlyList<ExternalSystemArtifact>? externalSystems = null,
IReadOnlyList<DatabaseConnectionArtifact>? databaseConnections = null,
IReadOnlyList<DataConnectionArtifact>? dataConnections = null)
=> new(
DeploymentId: deploymentId,
SharedScripts: sharedScripts,
ExternalSystems: externalSystems,
DatabaseConnections: databaseConnections,
NotificationLists: null,
DataConnections: dataConnections,
SmtpConfigurations: null,
Timestamp: DateTimeOffset.UtcNow);
[Fact]
public async Task ApplyingArtifacts_DeletesExternalSystemsAbsentFromTheSet()
{
var manager = CreateDeploymentManager();
manager.Tell(ArtifactsCommand("dep-es-1", externalSystems:
[
new ExternalSystemArtifact("MES", "https://mes", "ApiKey", null, null, 0),
new ExternalSystemArtifact("Legacy", "https://legacy", "Basic", null, null, 0)
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
Assert.Equal(2, await RowCountAsync("external_systems"));
// Second deployment: "Legacy" was deleted centrally, so the full set is just "MES".
manager.Tell(ArtifactsCommand("dep-es-2", externalSystems:
[
new ExternalSystemArtifact("MES", "https://mes", "ApiKey", null, null, 0)
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
Assert.Equal(1, await RowCountAsync("external_systems"));
}
[Fact]
public async Task ApplyingArtifacts_NullSet_TouchesNothing()
{
var manager = CreateDeploymentManager();
manager.Tell(ArtifactsCommand("dep-null-1", externalSystems:
[
new ExternalSystemArtifact("MES", "https://mes", "ApiKey", null, null, 0)
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
// A null list means "field not shipped", NOT "empty set" — it must not reconcile.
manager.Tell(ArtifactsCommand("dep-null-2", externalSystems: null));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
Assert.Equal(1, await RowCountAsync("external_systems"));
}
[Fact]
public async Task ApplyingArtifacts_RemovedSharedScript_IsUnregisteredFromLibrary()
{
var manager = CreateDeploymentManager();
manager.Tell(ArtifactsCommand("dep-ss-1", sharedScripts:
[
new SharedScriptArtifact("KeepScript", "return 1;", null, null),
new SharedScriptArtifact("GoneScript", "return 2;", null, null)
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
Assert.True(_sharedScriptLibrary.Contains("GoneScript"));
manager.Tell(ArtifactsCommand("dep-ss-2", sharedScripts:
[
new SharedScriptArtifact("KeepScript", "return 1;", null, null)
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
// Both the SQLite row and the compiled registration must be gone — a stale
// compiled delegate would keep the deleted script callable until restart.
Assert.Equal(1, await RowCountAsync("shared_scripts"));
Assert.True(_sharedScriptLibrary.Contains("KeepScript"));
Assert.False(_sharedScriptLibrary.Contains("GoneScript"));
}
[Fact]
public async Task ApplyingArtifacts_RemovedDataConnection_StopsLiveDclConnection()
{
var dclProbe = CreateTestProbe();
var manager = CreateDeploymentManager(dclManager: dclProbe.Ref);
manager.Tell(ArtifactsCommand("dep-dc-1", dataConnections:
[
new DataConnectionArtifact("PlcA", "OpcUa", "{}", null, 3),
new DataConnectionArtifact("PlcGone", "OpcUa", "{}", null, 3)
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
dclProbe.ExpectMsg<Commons.Messages.DataConnection.CreateConnectionCommand>(TimeSpan.FromSeconds(5));
dclProbe.ExpectMsg<Commons.Messages.DataConnection.CreateConnectionCommand>(TimeSpan.FromSeconds(5));
manager.Tell(ArtifactsCommand("dep-dc-2", dataConnections:
[
new DataConnectionArtifact("PlcA", "OpcUa", "{}", null, 3)
]));
Assert.True(ExpectMsg<ArtifactDeploymentResponse>(TimeSpan.FromSeconds(10)).Success);
// "PlcA" is unchanged (hash-cache skip), so the next DCL message must be the
// stop for the reconciled-away "PlcGone" — and the definition row is gone too.
var remove = dclProbe.ExpectMsg<DataConnectionLayer.Actors.RemoveConnectionCommand>(TimeSpan.FromSeconds(5));
Assert.Equal("PlcGone", remove.ConnectionName);
Assert.Equal(1, await RowCountAsync("data_connection_definitions"));
}
/// <summary>
/// In-test fake <see cref="IDeploymentConfigFetcher"/>: returns a canned config JSON
/// (notify-and-fetch success) or throws a canned exception (fetch failure), and records