feat(commons/transport): ScriptArtifactsChanged invalidation contract + post-commit publish from bundle import (consumer lands in plan 06)
New Commons seam: ScriptArtifactsChanged record + ScriptArtifactKinds + the IScriptArtifactChangeBus pub/sub interface. Host ships InProcessScriptArtifactChangeBus (lock-free copy-on-write, swallow-and-log per subscriber) registered as a central-role singleton. BundleImporter gains an optional IScriptArtifactChangeBus? and, AFTER tx.CommitAsync, publishes one notification per script-bearing kind (ApiMethod/ SharedScript/Template) whose resolution was Overwrite/Rename, using post-resolution names — fully guarded so a bad subscriber can't fail a committed import. Contract + plan-06 handoff table documented. No consumer yet (additive; publisher is correct). Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# Script-Artifact Invalidation Contract (`ScriptArtifactsChanged`)
|
||||
|
||||
**Status:** Seam + first producer shipped in PLAN-05 (arch-review 05, Task 14). Consumer + additional producers land in **plan 06**.
|
||||
|
||||
**Problem.** When a script-bearing artifact's definition changes at runtime — an Inbound API method's script, a shared script, or a template's scripts — any node that has already **compiled and cached** a handler/delegate for it keeps serving the stale version until a process restart. This is the root of two recorded project-memory gotchas (a DB-direct edit to a broken inbound method keeps returning "Script compilation failed" from `_knownBadMethods`; a non-compiling update leaves the old delegate serving). A bundle import is a third mutation path with the same hazard.
|
||||
|
||||
The fix is a small, explicit contract for "a script artifact changed → invalidate anything compiled from it," decoupled so producers (importer, management edits, failover) and consumers (compiled-handler caches) never reference each other.
|
||||
|
||||
## The seam (Commons)
|
||||
|
||||
```csharp
|
||||
// Commons/Messages/Scripting/ScriptArtifactsChanged.cs
|
||||
public sealed record ScriptArtifactsChanged(
|
||||
string ArtifactKind, // ScriptArtifactKinds.ApiMethod | SharedScript | Template
|
||||
IReadOnlyList<string> Names, // post-resolution (renamed) names
|
||||
string Source, // "BundleImport" | "Management" | "FailoverActivation"
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
public static class ScriptArtifactKinds
|
||||
{
|
||||
public const string ApiMethod = "ApiMethod";
|
||||
public const string SharedScript = "SharedScript";
|
||||
public const string Template = "Template";
|
||||
}
|
||||
|
||||
// Commons/Interfaces/Scripting/IScriptArtifactChangeBus.cs
|
||||
public interface IScriptArtifactChangeBus
|
||||
{
|
||||
void Publish(ScriptArtifactsChanged notification);
|
||||
IDisposable Subscribe(Action<ScriptArtifactsChanged> handler);
|
||||
}
|
||||
```
|
||||
|
||||
## Semantics (normative)
|
||||
|
||||
1. **Advisory, at-least-once.** A notification is a hint to invalidate, not a command with delivery guarantees. Duplicates are allowed; over-notification (e.g. an `Overwrite` resolution that actually fell through to `Add`) is allowed and harmless.
|
||||
2. **Published only AFTER commit.** A producer publishes *after* its mutating transaction commits — never inside it. A notification for a rolled-back write is worse than a missed one.
|
||||
3. **Never faults the producer.** The bus swallows-and-logs any subscriber-handler exception; the producer additionally guards its own publish call. A misbehaving consumer can never turn a committed write into a reported failure.
|
||||
4. **In-process, per node.** `InProcessScriptArtifactChangeBus` (Host) is node-local: a lock-free copy-on-write subscriber list. It does **not** cross the cluster. Cross-node and failover freshness are the **consumer's** responsibility — every consumer MUST also self-heal without a notification (e.g. a content-hash / revision check on next use), because a notification published on node A does not reach node B, and a node that was down during the change never hears it.
|
||||
5. **Names are post-resolution.** `Names` carries the artifact's persisted name after import name-resolution (a `Rename` carries `RenameTo`).
|
||||
|
||||
## What plan 05 ships
|
||||
|
||||
- The Commons seam (record + kinds + interface).
|
||||
- `InProcessScriptArtifactChangeBus` in the Host, registered as a central-role singleton.
|
||||
- The **Transport bundle importer as the first producer**: `BundleImporter.ApplyAsync` collects the Overwrite/Rename resolutions for `ApiMethod` / `SharedScript` / `Template` and publishes one `ScriptArtifactsChanged` per kind **after** `tx.CommitAsync`. The importer's dependency on the bus is optional (`IScriptArtifactChangeBus?`) so Transport-only hosts (and unit tests) run without it.
|
||||
|
||||
## Handoff to plan 06
|
||||
|
||||
Plan 06 implements the consumer side and the remaining producers:
|
||||
|
||||
| Item | Responsibility |
|
||||
|---|---|
|
||||
| **(a) Inbound API consumer** | `InboundScriptExecutor` subscribes to the bus and, on an `ApiMethod` notification, evicts the named entries from `_scriptHandlers` **and** `_knownBadMethods` — closing the "DB-direct edit keeps returning compile error" and "non-compiling update keeps old handler" gotchas. |
|
||||
| **(b) Management producers** | Publish `ScriptArtifactsChanged` from the `ManagementActor` `UpdateApiMethod` / shared-script / template-script edit paths (`Source = "Management"`), so a UI/CLI edit invalidates the same caches a bundle import does. |
|
||||
| **(c) Cross-node / failover freshness** | A revision-keyed handler cache (compile keyed by code hash — see PLAN-05 Task 15's `ScriptCompileVerdictCache`) so a node that missed the in-process notification still self-heals on next use, and a newly-active failover node never serves a stale compile. |
|
||||
|
||||
Until (a)–(c) land, plan 05's publisher is a no-op in practice (no subscribers), but the seam is stable and the producer is correct, so plan 06 is purely additive.
|
||||
@@ -0,0 +1,27 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// In-process, per-node publish/subscribe seam for
|
||||
/// <see cref="ScriptArtifactsChanged"/>. Producers (e.g. the Transport bundle
|
||||
/// importer, the ManagementActor script-editing paths) publish AFTER their mutating
|
||||
/// transaction commits; consumers (e.g. the Inbound API compiled-handler cache)
|
||||
/// subscribe and evict.
|
||||
/// <para>
|
||||
/// A conforming implementation MUST swallow-and-log any exception thrown by a
|
||||
/// subscriber's handler — a misbehaving consumer must never fault a producer's
|
||||
/// commit path.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IScriptArtifactChangeBus
|
||||
{
|
||||
/// <summary>Publishes a change notification to every current subscriber. Never throws on a subscriber fault.</summary>
|
||||
/// <param name="notification">The change notification to deliver.</param>
|
||||
void Publish(ScriptArtifactsChanged notification);
|
||||
|
||||
/// <summary>Subscribes a handler; dispose the returned token to unsubscribe.</summary>
|
||||
/// <param name="handler">The handler invoked for each published notification.</param>
|
||||
/// <returns>A disposable that removes the subscription when disposed.</returns>
|
||||
IDisposable Subscribe(Action<ScriptArtifactsChanged> handler);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Advisory notification that one or more script-bearing artifacts changed on this
|
||||
/// node. It is <b>at-least-once</b> and is published only <b>AFTER</b> the mutating
|
||||
/// transaction commits — never inside it (a notification for a rolled-back write is
|
||||
/// worse than a missed one).
|
||||
/// <para>
|
||||
/// Consumers MUST invalidate any compiled/cached artifact keyed by these names, and
|
||||
/// MUST ALSO be able to self-heal <i>without</i> the notification (e.g. a
|
||||
/// content-hash / revision check on next use) — this bus is <b>in-process, per
|
||||
/// node</b>; cross-node and failover propagation is the consumer's responsibility.
|
||||
/// See <c>docs/plans/2026-07-08-script-artifact-invalidation-contract.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="ArtifactKind">One of <see cref="ScriptArtifactKinds"/> — the kind of artifact that changed.</param>
|
||||
/// <param name="Names">The post-resolution (renamed) artifact names that changed.</param>
|
||||
/// <param name="Source">Origin of the change: <c>"BundleImport"</c>, <c>"Management"</c>, or <c>"FailoverActivation"</c>.</param>
|
||||
/// <param name="OccurredAtUtc">When the committing transaction completed.</param>
|
||||
public sealed record ScriptArtifactsChanged(
|
||||
string ArtifactKind,
|
||||
IReadOnlyList<string> Names,
|
||||
string Source,
|
||||
DateTimeOffset OccurredAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The canonical set of <see cref="ScriptArtifactsChanged.ArtifactKind"/> values —
|
||||
/// the artifact classes that carry compiled/cached script and therefore need
|
||||
/// invalidation when their definition changes.
|
||||
/// </summary>
|
||||
public static class ScriptArtifactKinds
|
||||
{
|
||||
/// <summary>An Inbound API method (its script handler is compiled and cached).</summary>
|
||||
public const string ApiMethod = "ApiMethod";
|
||||
|
||||
/// <summary>A shared script (compiled inline at every call site that references it).</summary>
|
||||
public const string SharedScript = "SharedScript";
|
||||
|
||||
/// <summary>A template (its scripts flatten into every instance built from it).</summary>
|
||||
public const string Template = "Template";
|
||||
}
|
||||
@@ -90,6 +90,13 @@ try
|
||||
// TransportOptions from ScadaBridge:Transport via BindConfiguration; no
|
||||
// explicit Configure needed.
|
||||
builder.Services.AddTransport();
|
||||
// Script-artifact invalidation bus (#05-T14): in-process, per-node pub/sub
|
||||
// for ScriptArtifactsChanged. The Transport bundle importer publishes
|
||||
// post-commit; the Inbound API compiled-handler cache subscribes as the
|
||||
// consumer (wired in plan 06). Central-only — it lives beside the importer.
|
||||
builder.Services.AddSingleton<
|
||||
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting.IScriptArtifactChangeBus,
|
||||
ZB.MOM.WW.ScadaBridge.Host.Services.InProcessScriptArtifactChangeBus>();
|
||||
// Audit Log — central node owns the AuditLogIngestActor singleton +
|
||||
// IAuditLogRepository. The site writer chain is still registered (lazy
|
||||
// singletons) but is never resolved on a central node.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-process, per-node <see cref="IScriptArtifactChangeBus"/>: a lock-free
|
||||
/// copy-on-write subscriber list. <see cref="Publish"/> snapshots the current
|
||||
/// handlers and invokes each, swallowing and logging any handler fault so a
|
||||
/// misbehaving consumer can never fault a producer's post-commit publish.
|
||||
/// <para>
|
||||
/// This is deliberately node-local — cross-node / failover propagation is the
|
||||
/// consumer's responsibility (content-hash / revision self-heal). See
|
||||
/// <c>docs/plans/2026-07-08-script-artifact-invalidation-contract.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class InProcessScriptArtifactChangeBus : IScriptArtifactChangeBus
|
||||
{
|
||||
private readonly Lock _writeLock = new();
|
||||
private readonly ILogger<InProcessScriptArtifactChangeBus> _logger;
|
||||
|
||||
// Copy-on-write: readers (Publish) take the reference without locking; writers
|
||||
// (Subscribe/unsubscribe) swap in a new immutable array under _writeLock.
|
||||
private volatile Action<ScriptArtifactsChanged>[] _handlers =
|
||||
Array.Empty<Action<ScriptArtifactsChanged>>();
|
||||
|
||||
/// <summary>Initializes the bus with a logger for swallowed handler faults.</summary>
|
||||
/// <param name="logger">Logger used to record subscriber handler exceptions.</param>
|
||||
public InProcessScriptArtifactChangeBus(ILogger<InProcessScriptArtifactChangeBus> logger) =>
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(ScriptArtifactsChanged notification)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(notification);
|
||||
|
||||
// Snapshot without locking — the array is never mutated in place.
|
||||
var handlers = _handlers;
|
||||
foreach (var handler in handlers)
|
||||
{
|
||||
try
|
||||
{
|
||||
handler(notification);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A bad consumer must NEVER fault the producer's commit path.
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Script-artifact-change subscriber threw handling {Kind} ({Count} names) from {Source}; ignored.",
|
||||
notification.ArtifactKind,
|
||||
notification.Names.Count,
|
||||
notification.Source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
lock (_writeLock)
|
||||
{
|
||||
var updated = new Action<ScriptArtifactsChanged>[_handlers.Length + 1];
|
||||
Array.Copy(_handlers, updated, _handlers.Length);
|
||||
updated[^1] = handler;
|
||||
_handlers = updated;
|
||||
}
|
||||
|
||||
return new Subscription(this, handler);
|
||||
}
|
||||
|
||||
private void Unsubscribe(Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
lock (_writeLock)
|
||||
{
|
||||
var index = Array.IndexOf(_handlers, handler);
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var updated = new Action<ScriptArtifactsChanged>[_handlers.Length - 1];
|
||||
Array.Copy(_handlers, 0, updated, 0, index);
|
||||
Array.Copy(_handlers, index + 1, updated, index, _handlers.Length - index - 1);
|
||||
_handlers = updated;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Subscription : IDisposable
|
||||
{
|
||||
private readonly InProcessScriptArtifactChangeBus _bus;
|
||||
private Action<ScriptArtifactsChanged>? _handler;
|
||||
|
||||
public Subscription(InProcessScriptArtifactChangeBus bus, Action<ScriptArtifactsChanged> handler)
|
||||
{
|
||||
_bus = bus;
|
||||
_handler = handler;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Idempotent: only the first Dispose removes the handler.
|
||||
var handler = Interlocked.Exchange(ref _handler, null);
|
||||
if (handler is not null)
|
||||
{
|
||||
_bus.Unsubscribe(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,10 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
@@ -80,6 +82,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// in a host that registers Transport without DeploymentManager — staleness is
|
||||
// then best-effort empty (informational only, never gates the import).
|
||||
private readonly IStaleInstanceProbe? _staleInstanceProbe;
|
||||
private readonly IScriptArtifactChangeBus? _scriptArtifactChangeBus;
|
||||
private readonly ILogger<BundleImporter>? _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -103,6 +106,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
|
||||
/// <param name="semanticValidator">Validates template references before applying.</param>
|
||||
/// <param name="staleInstanceProbe">Optional: recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.</param>
|
||||
/// <param name="scriptArtifactChangeBus">Optional: node-local bus notified AFTER a successful commit that script-bearing artifacts (ApiMethod / SharedScript / Template) were overwritten or renamed, so compiled-handler caches can invalidate. Null on hosts that don't register it (staleness/invalidation then relies on the consumer's own self-heal).</param>
|
||||
/// <param name="logger">Optional logger.</param>
|
||||
public BundleImporter(
|
||||
BundleSerializer bundleSerializer,
|
||||
@@ -123,6 +127,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
ScadaBridgeDbContext dbContext,
|
||||
SemanticValidator semanticValidator,
|
||||
IStaleInstanceProbe? staleInstanceProbe = null,
|
||||
IScriptArtifactChangeBus? scriptArtifactChangeBus = null,
|
||||
ILogger<BundleImporter>? logger = null)
|
||||
{
|
||||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||||
@@ -143,6 +148,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
|
||||
_staleInstanceProbe = staleInstanceProbe;
|
||||
_scriptArtifactChangeBus = scriptArtifactChangeBus;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -1112,6 +1118,14 @@ public sealed class BundleImporter : IBundleImporter
|
||||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
|
||||
// #05-T14: the write is now durable — advise any node-local compiled-
|
||||
// artifact cache that script-bearing artifacts changed so it can
|
||||
// invalidate by name. Published AFTER commit (never inside the
|
||||
// transaction — a notification for a rolled-back write is worse than a
|
||||
// missed one) and defensively guarded so a bad subscriber can never
|
||||
// turn a committed import into a reported failure.
|
||||
PublishScriptArtifactChanges(resolutions);
|
||||
|
||||
// T-007: zero out the decrypted plaintext BEFORE remove so any
|
||||
// caller-held reference (e.g. the Razor page that built the
|
||||
// ImportPreview) sees the cleared buffer too. Remove drops the
|
||||
@@ -1537,6 +1551,57 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// will still fall through to the unresolved-audit path — call sites are
|
||||
/// not rewritten in v1.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// #05-T14 — 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).
|
||||
/// </summary>
|
||||
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
|
||||
{
|
||||
if (_scriptArtifactChangeBus is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PublishKind(resolutions, "ApiMethod", ScriptArtifactKinds.ApiMethod);
|
||||
PublishKind(resolutions, "SharedScript", ScriptArtifactKinds.SharedScript);
|
||||
PublishKind(resolutions, "Template", ScriptArtifactKinds.Template);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The import already committed — a publish fault must never surface as
|
||||
// an import failure. Swallow-and-log (the bus itself also guards
|
||||
// per-subscriber, so this only catches pathological cases).
|
||||
_logger?.LogError(ex, "Publishing script-artifact-change notifications after a committed bundle import failed; ignored.");
|
||||
}
|
||||
|
||||
void PublishKind(IReadOnlyList<ImportResolution> all, string entityType, string kind)
|
||||
{
|
||||
var names = all
|
||||
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
|
||||
&& (r.Action is ResolutionAction.Overwrite or ResolutionAction.Rename))
|
||||
.Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
if (names.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_scriptArtifactChangeBus!.Publish(new ScriptArtifactsChanged(
|
||||
ArtifactKind: kind,
|
||||
Names: names,
|
||||
Source: "BundleImport",
|
||||
OccurredAtUtc: _timeProvider.GetUtcNow()));
|
||||
}
|
||||
}
|
||||
|
||||
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
|
||||
{
|
||||
var t = new Template(overrideName ?? dto.Name) { Description = dto.Description };
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Host.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #05-T14: unit tests for the in-process script-artifact change bus — delivery,
|
||||
/// unsubscribe, and the swallow-and-log guard that keeps a faulting subscriber from
|
||||
/// faulting the producer.
|
||||
/// </summary>
|
||||
public sealed class InProcessScriptArtifactChangeBusTests
|
||||
{
|
||||
private static InProcessScriptArtifactChangeBus NewBus() =>
|
||||
new(NullLogger<InProcessScriptArtifactChangeBus>.Instance);
|
||||
|
||||
private static ScriptArtifactsChanged Sample(string kind = ScriptArtifactKinds.ApiMethod) =>
|
||||
new(kind, new[] { "M1" }, "Management", DateTimeOffset.UnixEpoch);
|
||||
|
||||
[Fact]
|
||||
public void Publish_delivers_to_all_current_subscribers()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var a = new List<ScriptArtifactsChanged>();
|
||||
var b = new List<ScriptArtifactsChanged>();
|
||||
bus.Subscribe(a.Add);
|
||||
bus.Subscribe(b.Add);
|
||||
|
||||
var n = Sample();
|
||||
bus.Publish(n);
|
||||
|
||||
Assert.Same(n, Assert.Single(a));
|
||||
Assert.Same(n, Assert.Single(b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disposing_subscription_stops_delivery()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var received = new List<ScriptArtifactsChanged>();
|
||||
var token = bus.Subscribe(received.Add);
|
||||
|
||||
bus.Publish(Sample());
|
||||
token.Dispose();
|
||||
bus.Publish(Sample());
|
||||
|
||||
Assert.Single(received); // only the pre-dispose publish landed.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_is_idempotent()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var received = new List<ScriptArtifactsChanged>();
|
||||
var token = bus.Subscribe(received.Add);
|
||||
|
||||
token.Dispose();
|
||||
token.Dispose(); // must not throw nor corrupt the handler list.
|
||||
|
||||
bus.Publish(Sample());
|
||||
Assert.Empty(received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_faulting_subscriber_does_not_prevent_delivery_to_others_nor_throw()
|
||||
{
|
||||
var bus = NewBus();
|
||||
var good = new List<ScriptArtifactsChanged>();
|
||||
bus.Subscribe(_ => throw new InvalidOperationException("bad consumer"));
|
||||
bus.Subscribe(good.Add);
|
||||
|
||||
// Publish must not propagate the subscriber fault...
|
||||
var ex = Record.Exception(() => bus.Publish(Sample()));
|
||||
Assert.Null(ex);
|
||||
// ...and the well-behaved subscriber still receives it.
|
||||
Assert.Single(good);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_with_no_subscribers_is_a_noop()
|
||||
{
|
||||
var bus = NewBus();
|
||||
Assert.Null(Record.Exception(() => bus.Publish(Sample())));
|
||||
}
|
||||
}
|
||||
+132
@@ -5,12 +5,15 @@ using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||
@@ -42,6 +45,7 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
|
||||
public sealed class BundleImporterApplyTests : IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly RecordingScriptArtifactChangeBus _artifactBus = new();
|
||||
|
||||
public BundleImporterApplyTests()
|
||||
{
|
||||
@@ -89,6 +93,10 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddTransport();
|
||||
// #05-T14: a recording bus so the post-commit ScriptArtifactsChanged
|
||||
// publish is observable. BundleImporter takes IScriptArtifactChangeBus?
|
||||
// optionally, so this registration flows in via DI activation.
|
||||
services.AddSingleton<IScriptArtifactChangeBus>(_artifactBus);
|
||||
// #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
|
||||
// and the flatten/hash primitives in TemplateEngine — register both so
|
||||
// BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
|
||||
@@ -1324,4 +1332,128 @@ public sealed class BundleImporterApplyTests : IDisposable
|
||||
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
|
||||
Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds);
|
||||
}
|
||||
|
||||
// ============ #05-T14: post-commit ScriptArtifactsChanged publish ============
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_publishes_ScriptArtifactsChanged_per_kind_after_commit()
|
||||
{
|
||||
// Seed a Template, a SharedScript and an ApiMethod; Overwrite all three.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Pump") { Description = "tpl" };
|
||||
t.Scripts.Add(new TemplateScript("init", "return 1;"));
|
||||
ctx.Templates.Add(t);
|
||||
ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
|
||||
ctx.ApiMethods.Add(new ApiMethod("DoThing", "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: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
||||
SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(),
|
||||
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;
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
var session = await importer.LoadAsync(ms, passphrase: null);
|
||||
sessionId = session.SessionId;
|
||||
}
|
||||
|
||||
// Act — Overwrite all three (they still exist in the target).
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
await importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Overwrite, null),
|
||||
new("SharedScript", "HelperFn", ResolutionAction.Overwrite, null),
|
||||
new("ApiMethod", "DoThing", ResolutionAction.Overwrite, null),
|
||||
},
|
||||
user: "bob");
|
||||
}
|
||||
|
||||
// Assert — exactly one notification per kind, post-commit, with the names.
|
||||
var byKind = _artifactBus.Received.ToDictionary(n => n.ArtifactKind);
|
||||
Assert.Equal(3, _artifactBus.Received.Count);
|
||||
Assert.Equal(new[] { "DoThing" }, byKind[ScriptArtifactKinds.ApiMethod].Names);
|
||||
Assert.Equal(new[] { "HelperFn" }, byKind[ScriptArtifactKinds.SharedScript].Names);
|
||||
Assert.Equal(new[] { "Pump" }, byKind[ScriptArtifactKinds.Template].Names);
|
||||
Assert.All(_artifactBus.Received, n => Assert.Equal("BundleImport", n.Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_failed_apply_publishes_nothing()
|
||||
{
|
||||
// A template whose script calls an unresolved helper fails semantic
|
||||
// validation BEFORE commit, so nothing is published.
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
var t = new Template("Bad");
|
||||
t.Scripts.Add(new TemplateScript("init", "var x = UnknownHelper();"));
|
||||
ctx.Templates.Add(t);
|
||||
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: await ctx.Templates.Select(t => t.Id).ToListAsync(),
|
||||
SharedScriptIds: Array.Empty<int>(),
|
||||
ExternalSystemIds: Array.Empty<int>(),
|
||||
DatabaseConnectionIds: Array.Empty<int>(),
|
||||
NotificationListIds: Array.Empty<int>(),
|
||||
SmtpConfigurationIds: Array.Empty<int>(),
|
||||
ApiMethodIds: Array.Empty<int>(),
|
||||
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;
|
||||
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 Assert.ThrowsAsync<SemanticValidationException>(() =>
|
||||
importer.ApplyAsync(sessionId,
|
||||
new List<ImportResolution> { new("Template", "Bad", ResolutionAction.Add, null) },
|
||||
user: "bob"));
|
||||
}
|
||||
|
||||
Assert.Empty(_artifactBus.Received);
|
||||
}
|
||||
|
||||
/// <summary>Records every published notification for assertion.</summary>
|
||||
private sealed class RecordingScriptArtifactChangeBus : IScriptArtifactChangeBus
|
||||
{
|
||||
public List<ScriptArtifactsChanged> Received { get; } = new();
|
||||
|
||||
public void Publish(ScriptArtifactsChanged notification) => Received.Add(notification);
|
||||
|
||||
public IDisposable Subscribe(Action<ScriptArtifactsChanged> handler) =>
|
||||
throw new NotSupportedException("The recording bus does not support subscriptions.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user