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
@@ -1870,6 +1870,14 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
try
{
// Each non-null artifact list is the COMPLETE system-wide set
// (ArtifactDeploymentService ships GetAll* snapshots), so the apply is
// upsert-then-reconcile: store every incoming artifact, then delete any
// stored row absent from the set — that artifact was deleted centrally.
// Upsert-only apply used to leave such rows orphaned on the site forever
// (a centrally-deleted external system stayed callable from site scripts).
// A null list means "field not shipped" and touches nothing.
// Store shared scripts and recompile
if (command.SharedScripts != null)
{
@@ -1881,6 +1889,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// Shared scripts recompiled on update
_sharedScriptLibrary.CompileAndRegister(script.Name, script.Code);
}
var removedScripts = await _storage.DeleteSharedScriptsExceptAsync(
command.SharedScripts.Select(s => s.Name).ToList());
foreach (var name in removedScripts)
_sharedScriptLibrary.Remove(name);
}
// Store external system definitions
@@ -1891,6 +1904,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl,
es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson, es.TimeoutSeconds);
}
await _storage.DeleteExternalSystemsExceptAsync(
command.ExternalSystems.Select(es => es.Name).ToList());
}
// Store database connection definitions
@@ -1901,6 +1917,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString,
db.MaxRetries, db.RetryDelay);
}
await _storage.DeleteDatabaseConnectionsExceptAsync(
command.DatabaseConnections.Select(db => db.Name).ToList());
}
// Notification lists and SMTP
@@ -1923,6 +1942,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
dc.BackupConfigurationJson, dc.FailoverRetryCount);
}
var removedConnections = await _storage.DeleteDataConnectionDefinitionsExceptAsync(
command.DataConnections.Select(dc => dc.Name).ToList());
// After the SQLite store, dispatch an
// internal message back to the actor thread so the DCL
// push runs through EnsureDclConnection — keeping the
@@ -1934,8 +1956,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
// self-contained after artifact deployment"). The
// helper's hash cache skips unchanged definitions, so
// the push is idempotent for re-deploys of the same
// artifact bundle.
self.Tell(new ApplyArtifactDataConnectionsToDcl(command.DataConnections));
// artifact bundle. Reconciled removals ride the same
// message so the live DCL connection actor is stopped
// on the actor thread alongside the hash-cache eviction.
self.Tell(new ApplyArtifactDataConnectionsToDcl(
command.DataConnections, removedConnections));
}
// SMTP configuration is
@@ -2012,6 +2037,21 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
dc.BackupConfigurationJson,
dc.FailoverRetryCount);
}
// Reconciled removals: the definition row is already gone from SQLite; evict
// the hash cache (so a later re-add with the same name recreates cleanly) and
// stop the live DCL connection actor. A connection still referenced by a
// deployed instance's config no longer exists centrally either — its attributes
// go bad quality, the standard disconnected signal, until the instance is
// redeployed against current central config.
foreach (var name in msg.RemovedConnectionNames)
{
_createdConnections.Remove(name);
_dclManager?.Tell(new DataConnectionLayer.Actors.RemoveConnectionCommand(name));
_logger.LogWarning(
"Artifact reconcile removed data connection '{Name}' (deleted centrally); " +
"its DCL connection actor has been stopped", name);
}
}
/// <summary>
@@ -2118,8 +2158,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
/// <see cref="HandleDeployArtifacts"/>'s off-thread persistence task back
/// onto the actor thread, so the DCL push (and its hash-cache mutation)
/// runs through <see cref="EnsureDclConnection"/> without crossing
/// thread-confinement boundaries.
/// thread-confinement boundaries. <paramref name="RemovedConnectionNames"/>
/// carries the reconcile-deleted definitions so the hash-cache eviction and
/// live DCL connection stop also happen actor-thread-confined.
/// </summary>
internal record ApplyArtifactDataConnectionsToDcl(
IReadOnlyList<Commons.Messages.Artifacts.DataConnectionArtifact> DataConnections);
IReadOnlyList<Commons.Messages.Artifacts.DataConnectionArtifact> DataConnections,
IReadOnlyList<string> RemovedConnectionNames);
}
@@ -760,6 +760,93 @@ public class SiteStorageService
return results;
}
// ── Artifact set reconciliation ──
/// <summary>
/// Deletes shared scripts absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of shared script names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteSharedScriptsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("shared_scripts", keepNames);
/// <summary>
/// Deletes external system definitions absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of external system names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteExternalSystemsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("external_systems", keepNames);
/// <summary>
/// Deletes database connection definitions absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of database connection names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteDatabaseConnectionsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("database_connections", keepNames);
/// <summary>
/// Deletes data connection definitions absent from the supplied full set and returns the deleted names.
/// </summary>
/// <param name="keepNames">The complete set of data connection names that should exist.</param>
/// <returns>A task that resolves to the names of the rows that were deleted.</returns>
public Task<List<string>> DeleteDataConnectionDefinitionsExceptAsync(IReadOnlyCollection<string> keepNames)
=> DeleteRowsExceptAsync("data_connection_definitions", keepNames);
/// <summary>
/// Deletes every row of an artifact table whose <c>name</c> is not in
/// <paramref name="keepNames"/>, returning the deleted names.
///
/// Central always ships the COMPLETE system-wide set of each artifact class on an
/// artifact deployment, so a stored row absent from the incoming set means the
/// artifact was deleted centrally. The store methods are upsert-only, which used to
/// leave such rows orphaned on the site forever (a centrally-deleted external
/// system stayed callable from site scripts indefinitely); the artifact apply now
/// reconciles by calling this after upserting. An empty <paramref name="keepNames"/>
/// legitimately deletes every row — central saying "none of these exist anymore".
/// </summary>
private async Task<List<string>> DeleteRowsExceptAsync(string table, IReadOnlyCollection<string> keepNames)
{
await using var connection = OpenConnection();
await using var transaction = connection.BeginTransaction();
var keepParams = keepNames.Select((name, i) => (Key: $"@k{i}", Value: name)).ToList();
var predicate = keepParams.Count == 0
? string.Empty
: $" WHERE name NOT IN ({string.Join(", ", keepParams.Select(p => p.Key))})";
var removed = new List<string>();
await using (var select = connection.CreateCommand())
{
select.Transaction = transaction;
select.CommandText = $"SELECT name FROM {table}{predicate}";
foreach (var (key, value) in keepParams)
select.Parameters.AddWithValue(key, value);
await using var reader = await select.ExecuteReaderAsync();
while (await reader.ReadAsync())
removed.Add(reader.GetString(0));
}
if (removed.Count > 0)
{
await using var delete = connection.CreateCommand();
delete.Transaction = transaction;
delete.CommandText = $"DELETE FROM {table}{predicate}";
foreach (var (key, value) in keepParams)
delete.Parameters.AddWithValue(key, value);
await delete.ExecuteNonQueryAsync();
_logger.LogInformation(
"Artifact reconcile removed {Count} orphaned row(s) from {Table}: {Names}",
removed.Count, table, string.Join(", ", removed));
}
transaction.Commit();
return removed;
}
}
/// <summary>