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:
Joseph Doherty
2026-07-09 17:27:40 -04:00
parent 38edfefc42
commit 2c839def33
8 changed files with 527 additions and 0 deletions
@@ -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 };