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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user