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
@@ -131,6 +131,88 @@ public class ArtifactStorageTests : IAsyncLifetime, IDisposable
// Upsert should not throw
}
// ── Artifact set reconciliation ──
//
// Central always ships the COMPLETE system-wide set of each artifact class, so a
// stored row absent from the incoming set was deleted centrally. The store methods
// are upsert-only; without these deletes a centrally-deleted external system (or
// shared script / DB connection / data connection) stayed orphaned on the site
// forever and remained callable from site scripts.
[Fact]
public async Task DeleteSharedScriptsExcept_RemovesOrphans_KeepsPresent()
{
await _storage.StoreSharedScriptAsync("Keep1", "1", null, null);
await _storage.StoreSharedScriptAsync("Keep2", "2", null, null);
await _storage.StoreSharedScriptAsync("Orphan", "3", null, null);
var removed = await _storage.DeleteSharedScriptsExceptAsync(["Keep1", "Keep2"]);
Assert.Equal(["Orphan"], removed);
var names = (await _storage.GetAllSharedScriptsAsync()).Select(s => s.Name).Order().ToList();
Assert.Equal(["Keep1", "Keep2"], names);
}
[Fact]
public async Task DeleteExternalSystemsExcept_RemovesOrphans_KeepsPresent()
{
await _storage.StoreExternalSystemAsync("MES", "https://mes", "ApiKey", null, null);
await _storage.StoreExternalSystemAsync("Deleted", "https://old", "Basic", null, null);
var removed = await _storage.DeleteExternalSystemsExceptAsync(["MES"]);
Assert.Equal(["Deleted"], removed);
Assert.Equal(["MES"], await TableNamesAsync("external_systems"));
}
[Fact]
public async Task DeleteDatabaseConnectionsExcept_EmptyKeepSet_DeletesAll()
{
await _storage.StoreDatabaseConnectionAsync("DB1", "Server=a", 3, TimeSpan.FromSeconds(1));
await _storage.StoreDatabaseConnectionAsync("DB2", "Server=b", 3, TimeSpan.FromSeconds(1));
// An empty full set is legitimate: central saying no DB connections exist anymore.
var removed = await _storage.DeleteDatabaseConnectionsExceptAsync([]);
Assert.Equal(["DB1", "DB2"], removed.Order().ToList());
Assert.Empty(await TableNamesAsync("database_connections"));
}
[Fact]
public async Task DeleteDataConnectionDefinitionsExcept_RemovesOrphans_KeepsPresent()
{
await _storage.StoreDataConnectionDefinitionAsync("PlcA", "OpcUa", "{}");
await _storage.StoreDataConnectionDefinitionAsync("Gone", "OpcUa", "{}");
var removed = await _storage.DeleteDataConnectionDefinitionsExceptAsync(["PlcA"]);
Assert.Equal(["Gone"], removed);
var names = (await _storage.GetAllDataConnectionDefinitionsAsync()).Select(d => d.Name).ToList();
Assert.Equal(["PlcA"], names);
}
[Fact]
public async Task DeleteRowsExcept_NoOrphans_ReturnsEmpty_AndIsIdempotent()
{
await _storage.StoreExternalSystemAsync("MES", "https://mes", "ApiKey", null, null);
Assert.Empty(await _storage.DeleteExternalSystemsExceptAsync(["MES"]));
Assert.Empty(await _storage.DeleteExternalSystemsExceptAsync(["MES"]));
Assert.Equal(["MES"], await TableNamesAsync("external_systems"));
}
private async Task<List<string>> TableNamesAsync(string table)
{
await using var connection = _storage.CreateConnection();
await using var command = connection.CreateCommand();
command.CommandText = $"SELECT name FROM {table} ORDER BY name";
var names = new List<string>();
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
names.Add(reader.GetString(0));
return names;
}
// ── DeploymentManager-025 / SiteRuntime-031: central-only notif/SMTP purge ──
//
// Notification config is central-only. The site-side write paths and