fix(transport): script-artifact change publisher covers Add resolutions — delete-then-reimport no longer starves non-self-healing subscribers (plan R2-05 T5)
This commit is contained in:
@@ -1767,12 +1767,15 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Publishes one <see cref="ScriptArtifactsChanged"/> per script-bearing
|
||||
/// artifact kind (ApiMethod / SharedScript / Template) whose resolution was an
|
||||
/// Overwrite or Rename, using the post-resolution (renamed) names. Adds are
|
||||
/// excluded — nothing is cached under a brand-new name yet. Over-approximation is
|
||||
/// safe (an Overwrite that actually fell through to Add just triggers a harmless
|
||||
/// consumer re-check), which matches the bus's advisory, at-least-once contract.
|
||||
/// Fully guarded: never throws (it runs after the transaction has committed).
|
||||
/// artifact kind (ApiMethod / SharedScript / Template) for every non-Skip
|
||||
/// resolution — Overwrite, Rename, AND Add — using the post-resolution (renamed)
|
||||
/// names. An Add is NOT "nothing cached yet": an artifact deleted on the target and
|
||||
/// re-imported as Add under the same name can still have a stale
|
||||
/// compiled-handler/<c>_knownBadMethods</c> entry on any node, so it must notify too.
|
||||
/// Over-approximation stays safe (the bus is advisory, at-least-once; consumers
|
||||
/// self-heal), which matches its stated contract. Only Skip resolutions are excluded
|
||||
/// (nothing was written). Fully guarded: never throws (it runs after the transaction
|
||||
/// has committed).
|
||||
/// </summary>
|
||||
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
|
||||
{
|
||||
@@ -1799,7 +1802,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
{
|
||||
var names = all
|
||||
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
|
||||
&& (r.Action is ResolutionAction.Overwrite or ResolutionAction.Rename))
|
||||
&& r.Action is not ResolutionAction.Skip)
|
||||
.Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
+55
@@ -1397,6 +1397,61 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_for_Add_resolutions()
|
||||
{
|
||||
// ApiMethod imported as Add into a target where the name does not exist.
|
||||
// Delete-then-reimport-as-Add is the N3 edge: a node can still hold a
|
||||
// _knownBadMethods / compiled-handler entry under that very name, so Adds
|
||||
// must notify too (over-approximation is safe per the bus contract).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.ApiMethods.Add(new ApiMethod("DelmiaRecipeDownload", "return 1;") { TimeoutSeconds = 30 });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Guid sessionId;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var selection = new ExportSelection(
|
||||
TemplateIds: Array.Empty<int>(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: await ctx.ApiMethods.Select(m => m.Id).ToListAsync(),
|
||||
IncludeDependencies: false);
|
||||
var stream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
|
||||
passphrase: null, cancellationToken: CancellationToken.None);
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
// Delete the target method so the resolution is a genuine Add.
|
||||
ctx.ApiMethods.RemoveRange(await ctx.ApiMethods.ToListAsync());
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
sessionId = (await importer.LoadAsync(ms, passphrase: null)).SessionId;
|
||||
}
|
||||
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("ApiMethod", "DelmiaRecipeDownload", ResolutionAction.Add, null) },
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
var notification = Assert.Single(_artifactBus.Received,
|
||||
n => n.ArtifactKind == ScriptArtifactKinds.ApiMethod);
|
||||
Assert.Contains("DelmiaRecipeDownload", notification.Names); // FAILS today: Adds are filtered out
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user